Files app Widgets

Overview

Widgets are Web Components that have the following responsibility:

  1. Manage user input and events (Keyboard, Mouse, Touch, etc).
  2. Layout and style, the look & feel.
  3. Accessibility (a11y).
  4. Translation/Internationalization (i18n) & Localization (l10n), LTR & RTL (Left To Right & Right To Left).

Widgets should NOT:

  1. Handle business logic. Instead it should report the user actions or change of local state by emitting events.
  2. Call any private API or storage API.
  3. Use Polymer. Now LitElement is preferred.

How to create a new Widget?

import { XfBase, property, customElement, state, css, query } from './xf_base.js';

// Use @customElement decorator to link the component name with the component
// class, no `customElements.define` is required.
@customElement('xf-widget')
// XfBase is our base widget class, all the widgets should extend from it.
export class XfWidget extends XfBase {
    // Section: API contract.

    // Exposed property/attributes.
    // reflect: true is the key to connect attribute with this property.
    @property({type: String, reflect: true}) label = '';
    // We can specify a custom attribute name (otherwise the default
    // attribute name would be "haschildren").
    @property({type: Boolean, reflect: true, attribute: 'has-children'})
    hasChildren = false;

    // Emitted custom events.
    // All the events emitted by this widget should be defined here. Since it's
    // static, the consumer of the widget can also reference the event name by
    // `XfWidget.events.BUTTON_CLICKED`.
    static get events() {
      return {
        BUTTON_CLICKED: 'button_clicked',
      } as const;
    }

    // Exposed public methods.
    children() {
      // Return the slotted element.
      return this.items;
    }

    // Section: Styles.
    static get styles() {
      return getCSS();
    }

    // Section: Internal states.
    // Define internal variables here which will only be used inside the
    // render() (e.g. referred in the template).
    @state() private buttonClicked_ = false;

    // Section: Internal variables.
    // Define internal variables here which won't affect render()
    private boundOnWindowScrolled_ = this.onWindowScrolled_.bind(this);

    // Section: Child DOM elements.
    // We can query the child elements defined in the render() here.
    @query('button') $button!: HTMLButtonElement;
    // We can even query the slotted elements.
    @queryAssignedElements() items!: Array<HTMLSpanElement>;

    // Section: Constructor.
    constructor() {
      // Usually not needed, because the template defined in the render()
      // will be attached to shadow DOM automatically.
    }

    // Section: Render method.
    override render() {
      // * We can pass property/state or any other valid variables to the
      //   template here.
      // * We can event bind event directly on the element, no bind(this) is
      //   required, for example, the <button> click below.
      // * We can do conditional render like this with nested html tag, for
      //   example, only render "Clicked" when the state is true.
      return html`
        <span>${this.label}</span>
        <slot></slot>
        <button @click=${this.onButtonClicked_}>Update label</button>
        ${ this.buttonClicked_ ? html`<p>Clicked</p>` : '' }
      `;
    }

    // Section: Lifecycle methods.
    connectedCallback() {
      // Widget is being added to the document, add event listener for
      // global events.
      document.addEventListener('scroll', this.boundOnWindowScrolled_);
    }

    disconnectedCallback() {
      // Widget is being removed from the document, remove event listener
      // for global events to prevent memory leak.
      document.removeEventListener('scroll', this.boundOnWindowScrolled_);
    }

    // Section: Internal event handlers.
    private onButtonClicked_(e: MouseEvent) {
      this.label = 'something else';
      this.buttonClicked_ = true;
      // Once the property/state has been changed, no need to call render()
      // manually, it will be invoked automatically.

      // Dispatch custom event to the consumer.
      // IMPORTANT: use `bubbles: true, composed: true` to be able to traverse
      // across all Shadow DOMs.
      this.dispatchEvent(new CustomEvent(XfWidget.events.BUTTON_CLICKED, {
        bubbles: true,
        composed: true,
        detail: { label: this.label },
      }));
    }

    private onWindowScrolled_(e: Event) {
      // logic to respond window scroll
    }

    // Section: Other private helper methods.
    private myHelper_() {
      // random helper function
    }
}

// We are define all CSS outside the class so it's easier for
// Developer/Reviewer to focus on other logic in the class.
function getCSS() {
  return css`
    button {
      padding: 2px;
    }
  `;
}

// Section: Type definitions.
// Export the type for the event so caller code can use it in their listener.
export type WidgetButtonClickedEvent = CustomEvent<{label: string}>;
// TS way to tell the compiler about the types of our custom element.
declare global {
  // When dispatching `button_clicked` event that's the type of the event, aka:
  // event.detail.label exists and is a string.
  interface HTMLElementEventMap {
    [XfWidget.events.BUTTON_CLICKED]: WidgetButtonClickedEvent;
  }

  // When fetching the element from the DOM via:
  // document.querySelector('xf-widget');
  // this is the type returned (or null).
  interface HTMLElementTagNameMap {
    'xf-widget': XfWidget;
  }
}

LitElement Features

A lot of features from Native Web Component are also available in the context of LitElement.

Slots

Slots are used to populate parts of the template with content managed outside of the widget.

For example, with the above example xf-widget we can pass additional element as children to it.

<xf-widget>
  <span>child label</span>
</xf-widget>

Here the <span>child label</span> can replace the <slot></slot> in the above render() function.

Slots can also be used to named parts:

// render() function for `example-dialog` widget.
render() {
  return html`
    <h1><slot name="title">Dialog</slot></h1>
    <p><slot name="message"></slot></p>

    <div>
      <slot name="buttons">
        <button id="ok">Ok</button>
      </slot>
    </div>
  `;
}

The user of the <example-dialog> can fill in the slots like:

<example-dialog>
  <span slot="title">Delete?</span>
  <span slot="message">Do you want to delete the file "abc.txt"?</span>
  <div slot="buttons">
    <button id="ok">Yes</button>
    <button id="cancel">No</button>
  </div>
</example-dialog>

Content inside the slots can be styled using ::slotted() pseudo-element: https://developer.mozilla.org/en-US/docs/Web/CSS/::slotted

User Input & Events

The widget should convert the user input events like click, keydown, etc to widget's events, like “item-selected”. For example the native <select> emits the change event whenever the user selects an option either via keyboard or mouse.

The event listeners should be added by @<event> binding in the render() function to bind event handler to the corresponding event, which is illustrated in the above code snippet. Alternatively, we can also bind the event in the connectedCallback() and removed it in the disconnectedCallback(), see section Lifecycle Methods.

For TypeScript we should declare the types used in the emitted events, which is illustrated in the above code snippet.

Expose style

A widget can allow customization of its style using the following features:

render() {
  return html`
    <div part="tab active">Tab 1</div>
    <div part="tab">Tab 2</div>
    <div part="tab">Tab 3</div>
  `;
}

function getCSS() {
  return css`
    :host {
      color: var(--my-element-color, #f4f4f4);
      border-radius: var(--my-element-border-radius, 2px);
    }
  `;
}
<!-- Externally to <my-element> -->
<style>
  :root {
    --my-element-color: red;
    --my-element-border-radius: 4px;
  }
  /* style all the tabs. */
  my-element::part(tab) {
    background-color: cyan;
  }

  /* style the hover tab. */
  my-element::part(tab)::hover {
    background-color: magenta;
  }

  /* style the active/selected tab. */
  my-element::part(active) {
    background-color: blue;
    border: 1px solid grey;
  }
</style>

<my-element></my-element>

Lifecycle Methods

A widget can implement the following methods, see MDN for more information.

  • connectedCallback(): Invoked each time the custom element is appended into a document-connected element.
  • disconnectedCallback(): Invoked each time the custom element is disconnected from the document's DOM.
  • attributeChangedCallback(): Invoked each time one of the custom element's attributes is added, removed, or changed. Which attributes to notice change for is specified in a static get observedAttributes() method. With LitElement we barely need this callback.
  • adoptedCallback(): Invoked each time the custom element is moved to a new document.

A11y

tabindex and ARIA attributes, especially aria-label and role are used to enhance the content for users.

A good start point for a11y is to read ARIA best practices: https://www.w3.org/WAI/ARIA/apg/

Tests

Every widget should have its unittest checking the its exposed API. It should test:

  1. The events the widget emits, e.g.: Show/hide or open/close, content updated, user has selected something, etc.
  2. The behavior exposed, parts showing/hiding, the different status it can have, etc.
  3. Any internally calculated layout/style/dimensions, e.g.: positioning top/left, height/width, etc.

Since the render process is asynchronous and controlled by the Lit library, we need to explicitly wait for the render to be finished before we can do any assertion in the unit test. This is usually happening for:

  • the initial render
  • the re-render triggered by property/state change

In LitElement, we can rely on the await element.updateComplete to explicitly wait for the render to be completed.

// For initial render.
document.body.innerHTML = '<xf-widget></xf-widget>';
const element = document.querySelector('xf-widget')!;
element.label = 'abcd';
await element.updateComplete;

// For re-render.
const button = element.shadowRoot!.querySelector('button');
button.click();
await element.updateComplete;
// Assert label change.