July 28, 2021
Let’s build a Chrome extension with Web Components. That is what I said last night, and to be honest I never had any idea how to do it. I know that Google Chrome has a way to run complex code inside a isolated sandbox environment, but that is it.
So as always let’s set some goals and try to make it work.
So I decide to go with New Tab
extension and will go with the most basic and overly developed type of application, “A todo application”. But let start with one big disclaimer - this is super minimal and basic form of “Todo/Task” application, made for a demo purpose - so lot of corner cases and features will be skipped and left for some other time.
One important thing is the manifest.json
file - in other terms this is the package.json
file of Chrome Extensions. Everything that the browser must know about the extension is defined there.
1{2 "manifest_version": 2,3 "name": "Experimental New Tab Extension",4 "version": "0.0.0",5 "description": "Provide playground code",6 "icons": {7 "16": "static/icon16.png",8 "48": "static/icon48.png",9 "128": "static/icon128.png"10 },11 "chrome_url_overrides": {12 "newtab": "index.html"13 },14 "permissions": [15 "storage"16 ]17}
The storage
permission is something that we gonna need later but I’m adding it now so we don’t have to scroll back & front to find it later on. Oh and we will need to have some icons for our extension - this is something that I will leave to you to find.
The key thing here is chrome_url_overrides
and the newtab
key - this will let us overwrite the chrome://newtab
and replace it with our index.html
whatever it does - it’s up to you. Right now it does nothing and maybe is time to change it.
1<!doctype html>2<html>34<head lang='en'>5 <meta charset='utf-8'>6 <meta name='viewport' content='width=device-width'>7 <title>New Tab</title>8</head>910<body>11 <app-component></app-component>12 <script type="module" src='bundle.js'></script>13</body>1415</html>
And here again we are going too fast and we are not going to see anything cause we need to do two more things, load our extension and write our first component.
To load the extension Google Chrome have awesome documentation on the topic here. So I won’t cover it today.
Let’s create a simple proof of concept component. And test our new extension.
1import { LitElement, html } from "lit";2import { customElement } from "lit/decorators.js";34@customElement("app-component")5export default class AppComponent extends LitElement {6 render() {7 return html`<h1>Howdy!</h1>`;8 }9}1011declare global {12 interface HTMLElementTagNameMap {13 "app-component": AppComponent;14 }15}
If we are sure that our extension is loaded into Chrome, opening new tab will greet us with a nice message of Hawdy
.
Everything is working and there is nothing more that we could do. The End 👋.
Oh you are still here, huh? I want a Todo component that could persist the “todos” in a local storage and show them in a list, let me edit, delete and add new ones. And because I already done this I will start from top to bottom and leave the representation almost for the last step.
I wanted to explore how I could have a root component that could have one goal to keep the data and sync it - something common for a lot of components out there.
For my demo needs I don’t need to have everything that redux has to offer. I need something minimal so I gonna write a cheap version of it.
1type Reducers<T> = (data: T[], action: { action: string, data: any; }) => T[];23export default class Store<T> {4 private key: string;5 private data: T[] = [];6 private onUpdateFinish = (_data: any) => { };78 private reducers: Reducers<T> = (data: T[], _action: { action: string, data: any }) => { return data }910 constructor(key: string]) {11 this.data = [];12 this.key = key;13 }1415 public dispatch(action: string, data: any) {16 this.data = this.reducers(this.data, { action, data });17 this.sync();18 }1920 public onUpdate(callback: (data: T[]) => void) {21 this.onUpdateFinish = callback;2223 // eslint-disable-next-line no-undef24 chrome.storage.sync.get([this.key], (result: Record<string, any>) => {25 this.data = result[this.key] || [];26 this.sync();27 });28 }2930 public reduce(method: Reducers<T>) {31 this.reducers = method;32 }3334 sync() {35 // eslint-disable-next-line no-undef36 chrome.storage.sync.set({ [this.key]: this.data });37 this.onUpdateFinish(this.data);38 }39}
chrome.storage
is the one thing that comes with Chrome Extension API, it’s a wrapper around local storage - but also let us sync between Browsers that have the same account - useful for our feature self.
We gonna create this three basic methods that gonna use inside our wrapper component that we don’t have at the moment but i’ll be adding it later on.
reduce
is the method that will be used to change the reducer of the store. Or simple term - let us transform the data based on some actions. You know Redix way.
onUpdate
is our trigger it will return the data after we transform it and let us know when we need to do something else like re-render.
dispatch
that is easy, trigger some data change on the store.
The class will also hide the chrome.storage
calls and will only return the data to us. Everything else is just a nice wrapper and TypeScript magic.
So let’s define our data model and the reducer.
1export const TASK_INSERT_EVENT = "INSERT";2export const TASK_DROP_EVENT = "DROP";3export const TASK_UPDATE_EVENT = "UPDATE";45export type Task = {6 id: string;7 title: string;8 done: boolean;9};1011export const reducers = (store: Task[], request: Record<string, any>) => {12 switch (request.action) {13 case TASK_INSERT_EVENT:14 // Add new task on top of the array15 store = [request.data, ...store];16 break;17 case TASK_UPDATE_EVENT:18 store = store.map((task: Task) => {19 if (task.id === request.data.id) {20 return { ...task, ...request.data };21 }22 return task;23 });24 break;25 case TASK_DROP_EVENT:26 store = store.filter((task: Task) => task.id !== request.data.id);27 break;28 default:29 break;30 }3132 return store;3334}
As additional or let’s say a safety net we gonna provide a basic polyfill for the chrome.storage
so we could even try to test our code outside of the browser.
1// @ts-ignore2if (chrome?.storage === undefined) {34 const methods = {5 // @ts-ignore6 get: (items: string[], callback: (result: Record<string, any>) => void) => {7 // get from local storage8 let result: Record<string, any> = {};9 for (const key in items) {10 result[items[key]] = JSON.parse(window.localStorage.getItem(items[key]) + '');11 }12 callback(result);13 },14 // @ts-ignore15 set(items: { [key: string]: any; }) {16 // set to local storage17 for (const key in items) {18 window.localStorage.setItem(key, JSON.stringify(items[key]));19 }20 }21 }2223 // @ts-ignore24 chrome = {25 storage: {26 sync: methods,27 local: methods,28 }29 };30}
Now this will work for our development goals, but won’t let us sync between browsers - and that’s ok for the moment, we don’t have a extension that could benefit from that anyway.
The component that will be used to wrap the store around the task component and handle all the data changes and re-renders. Starting be reuse the main.ts
file.
1import { LitElement, html } from "lit";2import { customElement } from "lit/decorators.js";34import { reducers, Task } from './reducer';56import Store from "./store";78@customElement("app-component")9export default class AppComponent extends LitElement {1011 private store!: Store<Task>;1213 private tasks: Task[] = [];1415 connectedCallback() {16 super.connectedCallback();1718 this.store = new Store<Task>('tasks', []);19 this.store.reduce(reducers)2021 this.store.onUpdate((data: Task[]) => {22 this.tasks = data;23 this.requestUpdate();24 })25 }2627 render() {28 return html`29 <h1>Todo App</h1>30 `;31 }32}3334declare global {35 interface HTMLElementTagNameMap {36 "app-component": AppComponent;37 }38}
Ok so we have something but, how it works ? this.store
will be our data holder and instance of Store
class that we already done above. We gonna attach a function to the onUpdate
method that will be called when the data changes and will trigger requestUpdate
method to refresh the view.
the.store.reduce
will get the reducers that we defined into src/reducer.ts
and will be used to transform the data.
Everything seems to work but we still don’t have a todo app at this point. Nothing is creating task and nothing is updating them. So our next goal will be to make the action component that we started all of this.
So for our task component we gonna need to have a property that will accept Array of Tasks
from outside and bubble events to our wrapper when we want to change some of them. This way our top component will deal with all data related issues and our simple task component will only solve the view problem.
1import { LitElement, html, svg, unsafeCSS } from "lit";2import { customElement, property } from "lit/decorators.js";3import { classMap } from "lit/directives/class-map.js";4import { repeat } from 'lit/directives/repeat.js';56import styles from './styles.css';78import { TASK_DROP_EVENT, TASK_INSERT_EVENT, Task, TASK_UPDATE_EVENT } from './reducer';910export const TASKS_EVENT = 'DispatchChanges';1112const closeIcon = svg`13<svg14 class="icon"15 fill="none"16 stroke-linecap="round"17 stroke-linejoin="round"18 stroke-width="2"19 viewBox="0 0 24 24"20 stroke="currentColor"21>22 <path d="M6 18L18 6M6 6l12 12"></path>23</svg>24`;2526@customElement("app-tasks")27export default class AppTasks extends LitElement {2829 static styles = unsafeCSS(styles);3031 @property()32 tasks: Task[] = []3334 private onEnter(e: KeyboardEvent) {35 if (e.key === 'Enter') {36 this.createTask();37 }38 }3940 private statusChange(id: string, checked: boolean) {41 this.dispatchEvent(new CustomEvent(TASKS_EVENT, {42 detail: {43 action: TASK_UPDATE_EVENT,44 data: { id, done: checked }45 }46 }))47 }4849 private createTask() {50 const Entry = this.renderRoot.querySelector('#entry');5152 // @ts-ignore53 if (Entry && Entry.value === '') {54 return;55 }5657 this.dispatchEvent(new CustomEvent(TASKS_EVENT, {58 detail: {59 action: TASK_INSERT_EVENT,60 data: {61 id: `#${Math.random().toString(36).substr(2, 9)}`,62 // @ts-ignore63 title: Entry.value,64 done: false,65 }66 }67 }));6869 // @ts-ignore70 Entry.value = null;71 }7273 private editTask(id: string, title: string) {74 this.dispatchEvent(new CustomEvent(TASKS_EVENT, {75 detail: {76 action: TASK_UPDATE_EVENT,77 data: { id, title }78 }79 }))80 }8182 private drop(id: string) {83 this.dispatchEvent(new CustomEvent(TASKS_EVENT, {84 detail: {85 action: TASK_DROP_EVENT,86 data: { id }87 }88 }))89 }9091 render() {92 return html`93 <div class="container">94 <div class="card">95 <div class="title">Tasks</div>96 <div class="form">97 <input id="entry" type="text" @keyup="${this.onEnter}" placeholder="what is your plan for today" class="input" />98 </div>99 <ul class="tasks">100 ${this.tasks &&101 repeat(this.tasks, (task) => task.id, (task) => html`102 <li id="${task.id}" class="task">103 <div class="task-content ${classMap({ "task-done": task.done })}">104 <input @change=${(e: any)=> this.statusChange(task.id, e.target.checked)}105 .checked="${task.done}"106 type="checkbox"107 />108 <input type="text" @blur=${(e: any)=> this.editTask(task.id, e.target.value)}109 .value="${task.title}"110 class="task-edit"111 />112 </div>113 <button @click=${()=> this.drop(task.id)}>114 ${closeIcon}115 </button>116 </li>`117 )118 }119 </ul>120 </div>121 </div>122 `;123 }124}125126declare global {127 interface HTMLElementTagNameMap {128 "app-tasks": AppTasks;129 }130}
Our component will dispatch events for every action that we need to do and our wrapper component must handle it. Going with custom event is just so I could show how we could do that and don’t mess with some of already defined events.
1// Dispatch Event outside this component2this.dispatchEvent(new CustomEvent(TASKS_EVENT, {3 // Use `detail` to pass data4 detail: {5 // Action for our store6 action: TASK_INSERT_EVENT,7 data: {8 // Generate a random id for every new task9 id: GenerateId(),10 title: Entry.value,11 done: false,12 }13 }14}));
This event will be handle by app-component
and passed inside the store, there it will be added to the store internal copy of the data and in the same time sync with the Chrome internal storage.
Let’s integrate the two components into our app.
1// Add Task Component2import { TASKS_EVENT } from './task.component';34@customElement("app-component")5export default class AppComponent extends LitElement {67 // ...89 // Add additional methods to handle events10 async firstUpdated() {11 const AppTasks = this.renderRoot.querySelector('app-tasks');12 AppTasks?.addEventListener(TASKS_EVENT, this.handleChanges.bind(this))13 }1415 private handleChanges(event: any) {16 const { action, data } = event.detail;17 this.store.dispatch(action, data)18 }1920 // Update the render method21 render() {22 return html`23 <app-tasks .tasks=${this.tasks}></app-tasks>24 `;25 }26}
Now if we render the app we should see our new component working as best friends. Pretty much if this run from the first time - that is a miracle.
The result of all of this is that we have a HOC that will handle all the logic from where the data comes and how the data is modify if needed and let the Task component handle how the data is presented. We gonna have a super basic storage but in the same time a blueprint on how to implement something much more complex.
The how project could be find here in Github Repository - feel free to modify and use it as a starting point.
My name is Bozhidar Dryanovski and I'm a Software Engineer at Clarity
You should follow me on Twitter or Check my work at Github