Cally

Usage with existing components

Let's build a date range picker

You likely already have your own component library with inputs, buttons, popovers, and more. And you probably have your own design tokens too. So rather than re-implement all those parts, Cally's components are designed around composition and ease of integration.

In this guide we will use Shoelace as an example. But the principles apply to any other component library. We will not cover the finer details of Shoelace, but rather the generic high-level concepts.

What makes a date picker?

A date picker typically consists of:

Shoelace provides everything but the calendar. Using these lower-level pieces, we can compose together a more complex component. Additionally, shoelace provides a number of tokens for styling, which we can use to match its look and feel.

Calendar components

Let's start by styling the calendar components. We'll use Shoelace's card and icon components, plus some tokens.

<style>
  .flex {
    display: flex;
  }
  .gap-l {
    gap: var(--sl-spacing-large);
  }
  .flex-wrap {
    flex-wrap: wrap;
  }
  .justify-center {
    justify-content: center;
  }

  calendar-range {
    &::part(button) {
      border-radius: var(--sl-input-border-radius-small);
      background-color: var(--sl-input-background-color);
      border: 1px solid var(--sl-input-border-color);
      padding: var(--sl-spacing-x-small);
    }
  }

  calendar-month {
    --color-accent: var(--sl-color-primary-600);

    &::part(button) {
      border-radius: var(--sl-input-border-radius-medium);
    }
    &::part(range-inner) {
      border-radius: 0;
    }
    &::part(range-start) {
      border-start-end-radius: 0;
      border-end-end-radius: 0;
    }
    &::part(range-end) {
      border-start-start-radius: 0;
      border-end-start-radius: 0;
    }
    &::part(range-start range-end) {
      border-radius: var(--sl-input-border-radius-medium);
    }
  }
</style>
<sl-card>
  <calendar-range months="2">
    <sl-icon slot="previous" name="chevron-left" label="Previous"></sl-icon>
    <sl-icon slot="next" name="chevron-right" label="Next"></sl-icon>
    <div class="flex gap-l flex-wrap justify-center">
      <calendar-month></calendar-month> <calendar-month offset="1"></calendar-month>
    </div>
  </calendar-range>
</sl-card>

This is already looking good. Now we need to integrate with the rest of the components.

For more information on styling and theming the calendar components please see the full theming guide.

The input and toggle button

Let's use Shoelace's input, button, and icon components to build out the rest of the date picker.

<style>
  .flex {
    display: flex;
  }
  .gap-xs {
    gap: var(--sl-spacing-x-small);
  }
  .gap-l {
    gap: var(--sl-spacing-large);
  }
  .flex-wrap {
    flex-wrap: wrap;
  }
  .justify-center {
    justify-content: center;
  }
  .flex-1 {
    flex: 1;
  }
  .align-end {
    align-items: end;
  }

  calendar-range {
    &::part(button) {
      border-radius: var(--sl-input-border-radius-small);
      background-color: var(--sl-input-background-color);
      border: 1px solid var(--sl-input-border-color);
      padding: var(--sl-spacing-x-small);
    }
  }

  calendar-month {
    --color-accent: var(--sl-color-primary-600);
    &::part(button) {
      border-radius: var(--sl-input-border-radius-medium);
    }
    &::part(range-inner) {
      border-radius: 0;
    }
    &::part(range-start) {
      border-start-end-radius: 0;
      border-end-end-radius: 0;
    }
    &::part(range-end) {
      border-start-start-radius: 0;
      border-end-start-radius: 0;
    }
    &::part(range-start range-end) {
      border-radius: var(--sl-input-border-radius-medium);
    }
  }
</style>
<div class="flex gap-xs align-end">
  <sl-input label="Date range" class="flex-1"></sl-input>
  <sl-button> <sl-icon name="calendar-range" label="Toggle calendar"></sl-icon> </sl-button>
</div>
<sl-card>
  <calendar-range months="2">
    <sl-icon slot="previous" name="chevron-left" label="Previous"></sl-icon>
    <sl-icon slot="next" name="chevron-right" label="Next"></sl-icon>
    <div class="flex gap-l flex-wrap justify-center">
      <calendar-month></calendar-month> <calendar-month offset="1"></calendar-month>
    </div>
  </calendar-range>
</sl-card>

This is now starting to look like an actual date picker, but we need to show and hide the calendar on click.

Adding the popup

Shoelace provides a popup component that we can use to show and hide the calendar. The popup component is also responsible for positioning its content alongside some anchor element.

We'll place the card we've already built inside the popup. However, nothing will happen until we wire up the button to the popup's active state.

<style>
  .flex {
    display: flex;
  }
  .gap-xs {
    gap: var(--sl-spacing-x-small);
  }
  .gap-l {
    gap: var(--sl-spacing-large);
  }
  .flex-wrap {
    flex-wrap: wrap;
  }
  .justify-center {
    justify-content: center;
  }
  .flex-1 {
    flex: 1;
  }
  .align-end {
    align-items: end;
  }

  calendar-range {
    &::part(button) {
      border-radius: var(--sl-input-border-radius-small);
      background-color: var(--sl-input-background-color);
      border: 1px solid var(--sl-input-border-color);
      padding: var(--sl-spacing-x-small);
    }
  }

  calendar-month {
    --color-accent: var(--sl-color-primary-600);
    &::part(button) {
      border-radius: var(--sl-input-border-radius-medium);
    }
    &::part(range-inner) {
      border-radius: 0;
    }
    &::part(range-start) {
      border-start-end-radius: 0;
      border-end-end-radius: 0;
    }
    &::part(range-end) {
      border-start-start-radius: 0;
      border-end-start-radius: 0;
    }
    &::part(range-start range-end) {
      border-radius: var(--sl-input-border-radius-medium);
    }
  }
</style>
<div class="flex gap-xs align-end">
  <sl-input label="Date range" class="flex-1"></sl-input>
  <sl-button> <sl-icon name="calendar-range" label="Toggle calendar"></sl-icon> </sl-button>
</div>
<sl-popup placement="bottom-end" distance="8" auto-size="horizontal">
  <sl-card>
    <calendar-range months="2">
      <sl-icon slot="previous" name="chevron-left" label="Previous"></sl-icon>
      <sl-icon slot="next" name="chevron-right" label="Next"></sl-icon>
      <div class="flex gap-l flex-wrap justify-center">
        <calendar-month></calendar-month> <calendar-month offset="1"></calendar-month>
      </div>
    </calendar-range>
  </sl-card>
</sl-popup>

Adding interactivity

All the HTML and CSS is now in place, but nothing actually works. With a little javascript to wire up events, we can finish up the component and have a functional date range picker.

The <calendar-range> and <calendar-date> components both emit change events on selection. We can use this to set the value of the input and close the popup. Additionally, we can listen to the input event on the input to set the value of the calendar.

<style>
  .flex {
    display: flex;
  }
  .gap-xs {
    gap: var(--sl-spacing-x-small);
  }
  .gap-l {
    gap: var(--sl-spacing-large);
  }
  .flex-wrap {
    flex-wrap: wrap;
  }
  .flex-1 {
    flex: 1;
  }
  .align-end {
    align-items: end;
  }
  .justify-center {
    justify-content: center;
  }

  sl-card {
    max-inline-size: calc(var(--auto-size-available-width) - 20px);
  }

  calendar-range {
    &::part(button) {
      border-radius: var(--sl-input-border-radius-small);
      background-color: var(--sl-input-background-color);
      border: 1px solid var(--sl-input-border-color);
      padding: var(--sl-spacing-x-small);
    }
  }

  calendar-month {
    --color-accent: var(--sl-color-primary-600);
    &::part(button) {
      border-radius: var(--sl-input-border-radius-medium);
    }
    &::part(range-inner) {
      border-radius: 0;
    }
    &::part(range-start) {
      border-start-end-radius: 0;
      border-end-end-radius: 0;
    }
    &::part(range-end) {
      border-start-start-radius: 0;
      border-end-start-radius: 0;
    }
    &::part(range-start range-end) {
      border-radius: var(--sl-input-border-radius-medium);
    }
  }
</style>
<div class="flex gap-xs align-end">
  <sl-input label="Date range" class="flex-1"></sl-input>
  <sl-button id="toggle-button">
    <sl-icon name="calendar-range" label="Toggle calendar"></sl-icon>
  </sl-button>
</div>
<sl-popup placement="bottom-end" distance="8" anchor="toggle-button" auto-size="horizontal">
  <sl-card style="max-inline-size: calc(var(--auto-size-available-width) - 20px)">
    <calendar-range months="2">
      <sl-icon slot="previous" name="chevron-left" label="Previous"></sl-icon>
      <sl-icon slot="next" name="chevron-right" label="Next"></sl-icon>
      <div class="flex gap-l flex-wrap justify-center">
        <calendar-month></calendar-month> <calendar-month offset="1"></calendar-month>
      </div>
    </calendar-range>
  </sl-card>
</sl-popup>
<script>
  const root = document.currentScript.closest(".example");

  const input = root.querySelector("sl-input");
  const popup = root.querySelector("sl-popup");
  const toggle = root.querySelector("sl-button");
  const calendar = root.querySelector("calendar-range");

  function open() {
    popup.active = true;
    requestAnimationFrame(() => calendar.focus());
  }

  function close() {
    popup.active = false;
    toggle.focus();
  }

  toggle.addEventListener("click", () => {
    popup.active ? close() : open();
  });

  calendar.addEventListener("keydown", (e) => {
    if (e.key === "Escape") close();
  });

  calendar.addEventListener("change", (e) => {
    input.value = e.target.value;
    close();
  });

  input.addEventListener("input", (e) => {
    calendar.value = e.target.value;
  });
</script>

Finally

And we are done! Hopefully this hasn't been too daunting. If you are using a front-end framework like react or vue, you would likely wrap this up in a reusable component, adapting the javascript as necessary.

We haven't paid any mind to mobile styles throughout this guide. On mobile, perhaps you'd want to only show one month at a time. Or render an overlay rather than a popup. These decisions are beyond the scope of this guide, and are left as an exercise for the reader.

In terms of accessibility there is more that can be done. We should add aria-expanded to the toggle button, to communicate whether the popup is open or closed. Additionally, it might be helpful to communicate when a range selection has started and ended via a polite aria-live region. This could be achieved with the rangestart and rangeend events. We could also add a hint to the input via aria-describedby to describe the expected format for manual entry.

To find out more about each component, please see the respective component API docs. Or visit the theming guide for a more in-depth look at styling the components.