DEV Community

Andrew P
Andrew P

Posted on

Date Picker: reactive Web Component in JavaScript

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

In this tutorial we're going to implement such picker as a web component, using 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:

// src/index.js

class DatePicker extends HTMLElement {
}

customElements.define("date-picker", DatePicker);
Enter fullscreen mode Exit fullscreen mode

Now it's time to initialize the package manager:

  1. Install npm 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
Enter fullscreen mode Exit fullscreen mode

To bundle our code in this example we're going to use esbuild, because it's fast and easy to use.

So let's install it:

npm i -D esbuild
Enter fullscreen mode Exit fullscreen mode

... and bundle our code:

npx esbuild src/index.js --bundle --outdir=dist --target=es6
Enter fullscreen mode Exit fullscreen mode

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:

<!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>
Enter fullscreen mode Exit fullscreen mode

Now the directory structure looks like this:

.
├── dist
│   └── index.js
├── index.html
├── node_modules
│   └── ...
├── package-lock.json
├── package.json
└── src
    └── index.js
Enter fullscreen mode Exit fullscreen mode

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:

// 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>;
};
Enter fullscreen mode Exit fullscreen mode

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:

npm i -D viewmill
Enter fullscreen mode Exit fullscreen mode

Then let's invoke the tool:

npx viewmill --verbose --suffix "-view" src
Enter fullscreen mode Exit fullscreen mode

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:

// 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;
        });
    });
};
Enter fullscreen mode Exit fullscreen mode

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 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:

npm i viewmill-runtime
Enter fullscreen mode Exit fullscreen mode

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:

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;
Enter fullscreen mode Exit fullscreen mode

The return value is an object, which contains:

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;
};
Enter fullscreen mode Exit fullscreen mode

Knowing these details, let's integrate our view into the DatePicker component:

// 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);
Enter fullscreen mode Exit fullscreen mode

Styling

Web components are able to have their very own styles via the Shadow DOM API, so let's modify our component to make it a bit prettier :)

// 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);
Enter fullscreen mode Exit fullscreen mode

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

Picker

Well, it's only the beginning :)

Show month and dates

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

// 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();
}
Enter fullscreen mode Exit fullscreen mode

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

We're almost there :)

Picker

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 :)

// 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
        }
    }

    ...
}

...
Enter fullscreen mode Exit fullscreen mode

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

Picker

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

<!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>
Enter fullscreen mode Exit fullscreen mode

Top comments (0)