Date Picker: reactive Web Component in JavaScript
Tags: javascript, webdev, tutorial, webcomponents
Published: 2023-09-13 13:19:26

Developing a date picker could be a bit complicated, because almost every user interaction leads to massive UI updates.

Let's take a pretty simple picker:

![Picker](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2tww5siaatz5sp3x2nt3.gif)

In this tutorial we're going to implement such picker as a [web component](https://developer.mozilla.org/en-US/docs/Web/API/Web_components), using [viewmill](https://github.com/apleshkov/viewmill) to make the entire reactive view from just one JSX file _without_ React or something.

## Preparations

Let's make a project directory (e.g. `picker`) somewhere and make the `src` directory inside it.

Put there a file called `index.js` there:
```js
// src/index.js

class DatePicker extends HTMLElement {
}

customElements.define("date-picker", DatePicker);
```

Now it's time to initialize the package manager:
1. Install [npm](https://www.npmjs.com) if not yet.
2. Call `npm init` inside the project directory and follow the instructions.

The directory structure should look like this:
```
.
├── package.json
└── src
    └── index.js
```

To bundle our code in this example we're going to use [esbuild](https://esbuild.github.io), because it's fast and easy to use.

So let's install it:
```sh
npm i -D esbuild
```

... and bundle our code:
```sh
npx esbuild src/index.js --bundle --outdir=dist --target=es6
```

It creates the `dist` directory and put the bundled files there. Just the `index.js` file in our case.

Now create a file called `index.html`:
```html
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <title>DatePicker Example</title>
    <script src="./dist/index.js"></script>
</head>
<body>
    <date-picker />
</body>
</html>
```

Now the directory structure looks like this:
```
.
├── dist
│   └── index.js
├── index.html
├── node_modules
│   └── ...
├── package-lock.json
├── package.json
└── src
    └── index.js
```

Ok, we're finished with the preparations, so let's move on.

## The JSX-to-view transformation

Let's make a simple JSX-file called `picker.jsx` and put it to the `src` directory:
```jsx
// src/picker.jsx

export default (date) => {
    return <div class="picker">
        <header>
            <button data-prev-year>&lt;</button>
            <div>{date.getFullYear()}</div>
            <button data-next-year>&gt;</button>
        </header>
        <header>
            <button data-prev-mon>&lt;</button>
            <div>{date.getMonth()}</div>
            <button data-next-mon>&gt;</button>
        </header>
        <table>
            <thead>
                <tr>
                    <th>Mon</th>
                    <th>Tue</th>
                    <th>Wed</th>
                    <th>Thu</th>
                    <th>Fri</th>
                    <th>Sat</th>
                    <th>Sun</th>
                </tr>
            </thead>
        </table>
    </div>;
};
```

It's not a React component, but just a function, that returns JSX and takes the `date` argument.

Unfortunately, the function above isn't usable yet, so let's transform it to a _view_ with **viewmill**.

First we need to install it:
```sh
npm i -D viewmill
```

Then let's invoke the tool:
```sh
npx viewmill --verbose --suffix "-view" src
```

This command transforms any `*.jsx` file inside the `src` directory to a corresponding `*-view.js` file.

In our case we get `src/picker-view.js`, so let's look at it:
```js
// src/picker-view.js

import * as viewmill from "viewmill-runtime";

export default function (date) {
    return viewmill.view({
        date: viewmill.param(date)
    }, ({
        date
    }, unmountSignal) => {
        return viewmill.el('<div class="picker"><header><button data-prev-year><</button><div><!></div><button data-next-year>></button></header><header><button data-prev-mon><</button><div><!></div><button data-next-mon>></button></header><table><thead><tr><th>Mon</th><th>Tue</th><th>Wed</th><th>Thu</th><th>Fri</th><th>Sat</th><th>Sun</th></tr></thead></table></div>', (container, unmountSignal1) => {
            const div__1 = container.firstChild;
            const header__1 = div__1.firstChild;
            const button__1 = header__1.firstChild;
            const div__2 = button__1.nextSibling;
            const anchor__1 = div__2.firstChild;
            viewmill.unmountOn(unmountSignal1, viewmill.insert(viewmill.expr(() => (date.getValue().getFullYear()), [
                date
            ]), div__2, anchor__1));
            const button__2 = div__2.nextSibling;
            const header__2 = header__1.nextSibling;
            const button__3 = header__2.firstChild;
            const div__3 = button__3.nextSibling;
            const anchor__2 = div__3.firstChild;
            viewmill.unmountOn(unmountSignal1, viewmill.insert(viewmill.expr(() => (date.getValue().getMonth()), [
                date
            ]), div__3, anchor__2));
            const button__4 = div__3.nextSibling;
            const table__1 = header__2.nextSibling;
            const thead__1 = table__1.firstChild;
            const tr__1 = thead__1.firstChild;
            const th__1 = tr__1.firstChild;
            const th__2 = th__1.nextSibling;
            const th__3 = th__2.nextSibling;
            const th__4 = th__3.nextSibling;
            const th__5 = th__4.nextSibling;
            const th__6 = th__5.nextSibling;
            const th__7 = th__6.nextSibling;
        });
    });
};
```

So our function from `picker.jsx` is transformed to a function with the same argument, but now it returns a result of calling `viewmill.view(...)`.

Under the hood the long HTML string will be evaluated via the [`template`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template) element, and then all updatable components will be inserted before the corresponding `<!>` nodes.

Note the file imports the `viewmill-runtime` package, so we have to install it:
```sh
npm i viewmill-runtime
```

It's a thin package (~5KB minified), that contains some handy functions to manipulate view's DOM nodes and react to incoming values updates.

## How to use the view?

The `view` function returns an object, which has the `insert` method:
```ts
insert(
    // An element, into which a view is inserted
    target: Element,
    // An optional reference node, before which a view
    // is inserted. If not provided, then a view is
    // inserted at the end of `target`'s child nodes
    anchor?: Node | null
): InsertedView;
```

The return value is an object, which contains:
```ts
export type InsertedView = {
    // We'll use this later to attach event handlers
    querySelector(selectors: string): Element | null;
    querySelectorAll(selectors: string): Element[];
    // Removes the view nodes from DOM
    remove(): void;
    // Only unmounts the view without affecting DOM
    unmount(): void;
    // Is being aborted on `remove` or `unmount`
    unmountSignal: AbortSignal;
};
``` 

Knowing these details, let's integrate our view into the `DatePicker` component:
```js
// src/index.js

import View from "./picker-view";

class DatePicker extends HTMLElement {

    // Create a view displaying the current month
    #view = View(new Date());

    /** @type {() => void | undefined} */
    #unmounter;

    constructor() {
        super();
        this.attachShadow({ mode: "open" });
    }

    connectedCallback() {
        if (this.isConnected) {
            // Insert the view into the shadow root
            const { unmount } = this.#view.insert(this.shadowRoot);
            // Attach the unmounter function to call it on disconnect
            this.#unmounter = unmount;
        }
    }

    disconnectedCallback() {
        if (this.#unmounter) {
            this.#unmounter();
        }
    }
}

customElements.define("date-picker", DatePicker);
```

## Styling

Web components are able to have their very own styles via the [Shadow DOM](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM) API, so let's modify our component to make it a bit prettier :)

```js
// src/index.js

import View from "./picker-view";

class DatePicker extends HTMLElement {

    // Create a view displaying the current month
    #view = View(new Date());

    /** @type {() => void | undefined} */
    #unmounter;

    constructor() {
        super();
        const style = document.createElement("style");
        style.textContent = `
            .picker {
                width: fit-content;
                margin: 0 auto;
            }
            
            .picker header {
                display: flex;
                flex-direction: row;
                font-size: 1.5rem;
                margin-bottom: 1rem;
            }
            
            .picker header button {
                flex: 1;
            }
            
            .picker header div {
                flex: 2;
                text-align: center;
                font-weight: bold;
            }
            
            .picker th {
                text-align: center;
            }
            
            .picker td {
                cursor: pointer;
                text-align: center;
            }
            
            .picker td[data-other-month] {
                color: gray;
            }
            
            .picker td[data-current] {
                color: red;
                font-weight: bold;
            }
        `;
        const shadow = this.attachShadow({ mode: "open" });
        shadow.appendChild(style);
    }

    connectedCallback() {
        if (this.isConnected) {
            // Insert the view into the shadow root
            const { unmount } = this.#view.insert(this.shadowRoot);
            // Attach the unmounter function to call it on disconnect
            this.#unmounter = unmount;
        }
    }

    disconnectedCallback() {
        if (this.#unmounter) {
            this.#unmounter();
        }
    }
}

customElements.define("date-picker", DatePicker);
```

Then we need to re-bundle everything up, using the `npx esbuild ...` command from above.

![Picker](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/yhq4nvwj9nct7xk6qihu.png)

Well, it's only the beginning :)

## Show month and dates

Let's edit the `src/picker.jsx` and put some logic here:
```jsx
// src/picker.jsx

/**
 * @param {Date} date
 * @param {Date} [today]
 */
export default (
    date,
    today = new Date()
) => {
    // Extract the current month to a variable to re-use it
    const mon = date.getMonth();
    return <div class="picker">
        <header>
            <button data-prev-year>&lt;</button>
            <div>{date.getFullYear()}</div>
            <button data-next-year>&gt;</button>
        </header>
        <header>
            <button data-prev-mon>&lt;</button>
            {/* Displaying month */}
            <div>{monthAt(mon)}</div>
            <button data-next-mon>&gt;</button>
        </header>
        <table>
            <thead>
                <tr>
                    <th>Mon</th>
                    <th>Tue</th>
                    <th>Wed</th>
                    <th>Thu</th>
                    <th>Fri</th>
                    <th>Sat</th>
                    <th>Sun</th>
                </tr>
            </thead>
            <tbody>
                {/* 
                Iterating over the current dates
                using the child spread syntax 
                */}
                {...datesFor(date).map(
                    (week) => (
                        <tr>
                            {...week.map(
                                (d) => (
                                    <td
                                        data-time={
                                            // We'll use it later to handle the click
                                            d.getTime()
                                        }
                                        data-other-month={
                                            // A boolean attribute if it's a date
                                            // from another month nearby
                                            d.getMonth() !== mon
                                        }
                                        data-current={
                                            // A boolean attribute for the current date
                                            isDateEqual(d, today)
                                        }
                                    >
                                        {d.getDate()}
                                    </td>
                                )
                            )}
                        </tr>
                    )
                )}
            </tbody>
        </table>
    </div>;
};

/**
 * @param {number} i Month index
 * @returns {string}
*/
function monthAt(i) {
    switch (i) {
        case 0: return "Jan";
        case 1: return "Feb";
        case 2: return "Mar";
        case 3: return "Apr";
        case 4: return "May";
        case 5: return "Jun";
        case 6: return "Jul";
        case 7: return "Aug";
        case 8: return "Sep";
        case 9: return "Oct";
        case 10: return "Nov";
        case 11: return "Dec";
        default: throw new Error(`Unknown month index: ${i}`);
    }
}

/**
 * @param {Date} input 
 * @returns {Date[][]}
 */
function datesFor(input) {
    const startDate = new Date(input.getFullYear(), input.getMonth(), 1);
    // Seeking for Monday
    while (startDate.getDay() !== 1) {
        const d = startDate.getDate();
        startDate.setDate(d - 1);
    }
    // Generate six weeks with dates around the provided month
    return Array.from({ length: 6 }, (_, j) => (
        Array.from({ length: 7 }, (_, i) => {
            const d = new Date(startDate.getTime());
            const offset = (j * 7) + i;
            if (offset > 0) {
                d.setDate(d.getDate() + offset);
            }
            return d;
        })
    ));
}

/**
 * @param {Date} a 
 * @param {Date} b
 * @returns {boolean}
 */
function isDateEqual(a, b) {
    return a.getFullYear() === b.getFullYear()
        && a.getMonth() === b.getMonth()
        && a.getDate() === b.getDate();
}
```

Don't forget to re-run the `npx viewmill ...` command to get the new view.

We're almost there :)

![Picker](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/v2vya8sat9dqo8b0qp46.png)

## Event handlers

As already mentioned above, the view's `insert` method returns an object, that gives an ability to query the view's child nodes, using the CSS selectors.

So let's modify the component's `connectedCallback` method to handle the buttons to navigate through time :)

```js
// src/index.js

...

class DatePicker extends HTMLElement {

    ...

    /** @type {Date | undefined} */
    #currentValue;

    get value() {
        return this.#currentValue;
    }

    connectedCallback() {
        if (this.isConnected) {
            const {
                unmount,
                unmountSignal,
                querySelector
            } = this.#view.insert(this.shadowRoot);
            this.#unmounter = unmount;
            querySelector(".picker")?.addEventListener("click", (e) => {
                if (e.target instanceof HTMLButtonElement) {
                    const btn = e.target;
                    // Extract the model's `date` parameter to a variable
                    const { date } = this.#view.model;
                    if (btn.hasAttribute("data-prev-year")) {
                        // Prev year
                        date.updateValue((date) => (
                            new Date(date.getFullYear() - 1, date.getMonth())
                        ));
                    } else if (btn.hasAttribute("data-next-year")) {
                        // Next year
                        date.updateValue((date) => (
                            new Date(date.getFullYear() + 1, date.getMonth())
                        ));
                    }
                    if (btn.hasAttribute("data-prev-mon")) {
                        // Prev month
                        date.updateValue((date) => {
                            let y = date.getFullYear();
                            let m = date.getMonth();
                            if (m === 0) {
                                // If `m` is Jan
                                y -= 1;
                                m = 11;
                            } else {
                                m -= 1;
                            }
                            return new Date(y, m);
                        });
                    } else if (btn.hasAttribute("data-next-mon")) {
                        // Next month
                        date.updateValue((date) => {
                            let y = date.getFullYear();
                            let m = date.getMonth();
                            if (m === 11) {
                                // If `m` is Dec
                                y += 1;
                                m = 0;
                            } else {
                                m += 1;
                            }
                            return new Date(y, m);
                        });
                    }
                } else if (e.target instanceof HTMLTableCellElement) {
                    const cell = e.target;
                    if (cell.hasAttribute("data-time")) {
                        // Date cell
                        const t = +cell.getAttribute("data-time");
                        this.#currentValue = new Date(t);
                        this.dispatchEvent(new Event("change"));
                    }
                }
            }, { signal: unmountSignal });
            //           ^^^^^^^^^^^^^ Note how the signal is used
        }
    }

    ...
}

...
```

Let's re-run the bundle command and open the `index.html` file:

![Picker](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b7hl6j41ldnty0841ijh.gif)

Now we can listen to the component's `change` event and read its value, e.g. merely in the `index.html` file:

```html
<!doctype html>
<html lang="en">
<head>
    ...
</head>
<body>
    <date-picker />
    <script>
        document.querySelector("date-picker").addEventListener(
            "change",
            (e) => console.log(e.target.value)
        );
    </script>
</body>
</html>
```