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:
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);
Now it's time to initialize the package manager:
- Install npm if not yet.
- 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, because it's fast and easy to use.
So let's install it:
npm i -D esbuild
... and bundle our code:
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
:
<!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:
// src/picker.jsx
export default (date) => {
return <div class="picker">
<header>
<button data-prev-year><</button>
<div>{date.getFullYear()}</div>
<button data-next-year>></button>
</header>
<header>
<button data-prev-mon><</button>
<div>{date.getMonth()}</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>;
};
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
Then let's invoke the tool:
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:
// 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
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
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;
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;
};
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);
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);
Then we need to re-bundle everything up, using the npx esbuild ...
command from above.
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><</button>
<div>{date.getFullYear()}</div>
<button data-next-year>></button>
</header>
<header>
<button data-prev-mon><</button>
{/* Displaying month */}
<div>{monthAt(mon)}</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>
<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 :)
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
}
}
...
}
...
Let's re-run the bundle command and open the index.html
file:
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>
Top comments (0)