Tony Messias

September 2, 2024

The Duet Date Picker

Duet Design System is a collection of components. Unfortunately, their components are not entirely open-source, except for the Date Picker. It's a bummer, I know, their components are SO GOOD! And they're built around Web Components, which makes it all even better.

Anyway, let's focus on the Date Picker. While browsing a Hotwired app, I noticed they were using this date picker so I decided to give it a shot.

I was able to get it working in my app, matching my styles and all that:

Screenshot from 2024-09-02 14-55-06.png


Let's see how to make this work.

Installation

Since I'm using Importmap Laravel, we can pin the Duet component like so:

php artisan importmap:pin @duetds/date-picker/custom-element

This only installs the Web Component, but this doesn't register the element itself. To do so, we need to create our own element:

import { DuetDatePicker } from "@duetds/date-picker/custom-element";

if (customElements.get("duet-date-picker") === undefined) {
    customElements.define("duet-date-picker", DuetDatePicker);
}

Now, we can use the new element anywhere in our app like so:

<duet-date-picker />

With this, you'll get the same element as in their docs. However, the implementation I saw was wrapping it in another custom element to augment its behavior. To build a similar version, I've added a new element called `<date-input>`:

class DateFormatter {
    constructor(date) {
        this.date = date
    }

    get formattedDate() {
        return `${this.formattedWeekday}, ${this.formattedMonthAndYear}`
    }

    // Private

    get formattedMonthAndYear() {
        return `${this.formattedMonth}${this.formattedYear}`
    }

    get formattedWeekday() {
        if (this.isToday) return "Today"
        if (this.isTomorrow) return "Tomorrow"

        return this.weekdayFormatter.format(this.date)
    }

    get isToday() {
        return this.date.toDateString() == new Date().toDateString()
    }

    get isTomorrow() {
        return this.date.toDateString() == new Date(
            new Date().setDate(new Date().getDate() + 1)
        ).toDateString()
    }

    get weekdayFormatter() {
        return new Intl.DateTimeFormat("en", {
            weekday: "short",
        })
    }

    get formattedMonth() {
        return this.monthFormatter.format(this.date)
    }

    get monthFormatter() {
        return new Intl.DateTimeFormat("en", {
            day: "numeric",
            month: "short",
        })
    }

    get formattedYear() {
        if (this.date.getFullYear() == this.currentYear) return ""

        return `, ${this.date.getFullYear()}`
    }

    get currentYear() {
        return new Date().getFullYear()
    }
}

class DateInputElement extends HTMLElement {
    #duetPicker
    #originalDate

    async connectedCallback() {
        this.querySelectorAll("duet-date-picker").forEach(
            (element) => element.remove()
        )

        this.#duetPicker = this.#createDuetPicker()

        this.addEventListener("duetFocus", this.#duetPickerFocused)

        this.setAttribute("initialized", "")
        this.#originalDate = this.date
    }

    disconnectedCallback() {
        this.#duetPicker.remove()

        this.removeAttribute("initialized")
    }

    open() {
        this.#duetPicker.show()
    }

    reset() {
        this.date = this.#originalDate
    }

    get name() {
        return this.getAttribute("name")
    }

    get value() {
        return this.getAttribute("value") || ""
    }

    set value(value) {
        if (value != null) {
            this.#duetPicker.value = value
            this.setAttribute("value", value)
        } else {
            this.#duetPicker.value = null
            this.removeAttribute("value")
        }
    }

    get date() {
        return this.value
            ? this.#duetPicker.dateAdapter.parse(this.value)
            : null
    }

    set date(date) {
        const originalDate = this.date

        // Force the value to clear...
        if (!date) this.input.value = ""

        if (originalDate?.getTime() != date?.getTime() && this.#isValidDate(date)) {
            this.#duetPicker.setValue(date)
        }

        // Forces a refresh...
        this.input.value = this.#formatDate(originalDate)
    }

    async focus() {
        await nextFrame()

        this.input.focus()
        this.input.select()

        return Promise.resolve()
    }

    get input() {
        return this.#duetPicker.datePickerInput
    }

    get isOpen() {
        return this.#duetPicker.open
    }

    get disabled() {
        return this.hasAttribute("disabled")
    }

    set disabled(value) {
        const newDisabled = !!value
        this.#duetPicker.disabled = newDisabled
        this.toggleAttribute("disabled", newDisabled)
    }

    get maxDate() {
        return this.getAttribute("data-max-date")
    }

    set maxDate(value) {
        this.#duetPicker.max = value
        this.setAttribute("data-max-date", value)
    }

    get minDate() {
        return this.getAttribute("data-min-date")
    }

    set minDate(value) {
        this.#duetPicker.min = value
        this.setAttribute("data-min-date", value)
    }

    #formatDate = (date) => {
        return new DateFormatter(date).formattedDate
    }

    #isValidDate(date) {
        return (!this.minDate || date >= this.#parseDate(this.minDate))
            && (!this.maxDate || date <= this.#parseDate(this.maxDate))
    }

    #parseDate(dateString) {
        const dateParts = dateString.split("-")

        return new Date(
            parseInt(dateParts[0]),
            parseInt(dateParts[1]) - 1,
            parseInt(dateParts[2]),
        )
    }

    #createDuetPicker() {
        const attributes = {}
        attributes.value = this.value || ""

        if (this.id) attributes.identifier = `${this.id}_picker`
        if (this.disabled) attributes.disabled = "disabled"
        if (this.required) attributes.required = "required"
        if (this.maxDate) attributes.max = this.maxDate
        if (this.minDate) attributes.min = this.minDate
        if (this.name) attributes.name = this.name

        const duetPicker = newElement("duet-date-picker", attributes)
        duetPicker.dateAdapter.format = this.#formatDate
        duetPicker.localization.placeholder = "Choose date..."
        this.appendChild(duetPicker)

        return duetPicker
    }

    #duetPickerFocused = async () => {
        await this.focus()

        this.input.addEventListener("keydown", this.#inputkeyDownPressed)
        this.input.readOnly = true
    }

    #inputkeyDownPressed = (event) => {
        switch (event.key) {
            case "Delete":
            case "Backspace":
                this.value = ""
                break;
            case " ":
            case "ArrowDown":
                this.open()
                break
            case "Enter":
                event.preventDefault()
                event.stopPropagation()
                break
        }
    }
}

async function nextFrame() {
    return new Promise(requestAnimationFrame)
}

function newElement(tagName, attributes) {
    const element = document.createElement(tagName)

    for (const name in attributes) {
        const value = attributes[name];
        element.setAttribute(name, value)
    }

    return element
}

if (customElements.get("date-input") === undefined) {
    customElements.define("date-input", DateInputElement)
}

Now, we can use it like:

<date-input />

Here's how I use the input in my form inside a Blade partial:

<div>
    <x-input-label for="name" :value="__('Birthdate')" />
    <date-input 
        class="mt-1 block w-full"
        name="birthdate"
        required
        value="{{ old('birthdate', $user->birthdate->toDateString()) }}"
        expand="true"
        identifier="birthdate"
        increment="100"
     ></date-input>
    <x-input-error class="mt-2" :messages="$errors->get('birthdate')" />
</div>

To style it, we can use CSS variables. To match my styles, I used this setup (I'm using Tailwind CSS):

@tailwind base;
@tailwind components;
@tailwind utilities;

:root {
    --duet-color-primary: theme('colors.orange.600');
    --duet-color-text: theme('colors.neutral.900');
    --duet-color-text-active: theme('colors.neutral.100');
    --duet-color-placeholder: theme('colors.neutral.200/0.8');
    --duet-color-button: theme('colors.white');
    --duet-color-surface: theme('colors.white');
    --duet-color-overlay: theme('colors.neutral.900/0.8')
    --duet-color-border: theme('colors.white')

    --duet-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    --duet-font-normal: 400;
    --duet-font-bold: 600;

    --duet-radius: 4px;
    --duet-z-index: 600;

    @media (prefers-color-scheme: dark) {
        --duet-color-text: theme('colors.neutral.200');
        --duet-color-text-active: theme('colors.neutral.900');
        --duet-color-placeholder: theme('colors.neutral.600/0.8');
        --duet-color-button: theme('colors.neutral.900');
        --duet-color-surface: theme('colors.neutral.900');
        --duet-color-overlay: theme('colors.neutral.900/0.8');
        --duet-color-border: theme('colors.neutral.300');
    }
}

.duet-date__input {
    @apply px-3 py-2 leading-6 border-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 focus:ring-1 focus:border-orange-500 dark:focus:border-orange-600 focus:ring-orange-500 dark:focus:ring-orange-600 rounded-md shadow-sm;

    &:focus {
        @apply !outline-none shadow-none;
    }
}

We're wrapping it in our custom element to augment it with things like:

  • We're replacing the DateFormatter which creates a more human-friendly date that will be what the input displays. Under the hood, the Duet Date Picker still sends an ISO-8601 value (YYYY-MM-DD) to the backend, but we display something like "Today, Sep 2", "Tomorrow, Sep 3", or "Wed, Sep 6, 2023" for older dates and things like that
  • The text input is read-only, users can only interact with the Date Picker button on the side of the input. To open with the keyboard, we're augmenting it to open the date picker when pressing the spacebar or the down arrow keys

---

This implementation was based on how they're using it on HEY, I saw it using View Source, and decided to try to implement it in my app. Thanks to the Duet DS team for making this component open-source and the HEY team for allowing View Source!

About Tony Messias