| # Files app Widgets |
| |
| ## Overview |
| |
| Widgets are |
| [Web Components](https://developer.mozilla.org/en-US/docs/Web/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](https://lit.dev/) is preferred. |
| |
| ## How to create a new Widget? |
| |
| ```typescript |
| 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](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_templates_and_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. |
| |
| ```html |
| <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: |
| |
| ```typescript |
| // 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: |
| |
| ```html |
| <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](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/change_event) |
| 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](#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: |
| |
| * [CSS variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) |
| * [part & ::part()](https://developer.mozilla.org/en-US/docs/Web/CSS/::part) |
| and |
| [exportparts](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/exportparts) |
| |
| ```typescript |
| 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); |
| } |
| `; |
| } |
| ``` |
| |
| ```html |
| <!-- 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](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#using_the_lifecycle_callbacks) |
| 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`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/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](https://lit.dev/docs/components/lifecycle/#updatecomplete) |
| to explicitly wait for the render to be completed. |
| |
| ```typescript |
| // 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. |
| ``` |