Skip to content

Date Picker

USES Day.js

This component uses Day.js. Read more about using your own instance of Day.js with the component under the Day.js section.

NVDA REQUIRES FOCUS MODE ENABLED TO NAVIGATE AS EXPECTED

When entering a table in NVDA, it goes into a browse mode for most users. For a better experience, focus mode should be enabled.

To make users with screen readers aware of this, in the example below we use <PeachyVisuallyHidden /> to let them know that if they're using NVDA, we recommend turning on focus mode.


vue
<template>
  <PeachyInput.Input class="date">
    <PeachyDatePicker.Input
      v-bind="$attrs"
      v-model:date="selectedDate"
      placeholder="M/D/YYYY">
      <template #before>
        <PeachyInput.Label>Date</PeachyInput.Label>
      </template>

      <template #after>
        <PeachyDialog.Dialog>
          <PeachyDialog.Trigger
            toggle
            class="date-picker-button"
            aria-label="Pick date">
            <CalendarSvg />
          </PeachyDialog.Trigger>

          <Teleport to="body">
            <PeachyDialog.Target
              close-on-outside-click
              class="date-picker-dialog"
              placement="bottom">
              <PeachyVisuallyHidden>
                <PeachyDialog.Title>Date picker</PeachyDialog.Title>
              </PeachyVisuallyHidden>

              <PeachyDatePicker.Calendar
                v-model:date="focusedDate"
                v-slot="{ weeks, headings }">
                <div class="date-picker-navigation-wrapper">
                  <button
                    aria-label="Previous month"
                    @click="focusedDate = focusedDate.add(-1, 'months')">
                    <ChevronSvg class="flip-icon" />
                  </button>

                  <PeachyDatePicker.Title>
                    {{ capitalize(focusedDate?.format("MMMM YYYY")) }}
                  </PeachyDatePicker.Title>

                  <button
                    aria-label="Next month"
                    @click="focusedDate = focusedDate.add(1, 'months')">
                    <ChevronSvg />
                  </button>
                </div>

                <PeachyDatePicker.Table>
                  <PeachyDatePicker.Description>
                    <PeachyVisuallyHidden is="span">
                      If using NVDA, enter focus mode to navigate the dates as
                      intended.
                    </PeachyVisuallyHidden>

                    Pick a date for your reservation.
                  </PeachyDatePicker.Description>

                  <PeachyDatePicker.Head>
                    <PeachyDatePicker.Headings>
                      <td></td>

                      <PeachyDatePicker.Heading
                        v-for="day in headings"
                        :key="day">
                        {{ day.substring(0, 2).toUpperCase() }}
                      </PeachyDatePicker.Heading>
                    </PeachyDatePicker.Headings>
                  </PeachyDatePicker.Head>

                  <PeachyDatePicker.Body>
                    <PeachyDatePicker.Week
                      v-for="week in Object.keys(weeks)"
                      :key="week">
                      <PeachyDatePicker.WeekNumber>
                        {{ week }}
                      </PeachyDatePicker.WeekNumber>

                      <PeachyDatePicker.Day
                        v-for="day in weeks[parseInt(week)]"
                        :key="day.get('date')"
                        :date="day">
                        <span aria-hidden="true">
                          {{ day.format("D") }}
                        </span>

                        <PeachyVisuallyHidden>
                          {{ day.format("D MMM YYYY") }}
                        </PeachyVisuallyHidden>
                      </PeachyDatePicker.Day>
                    </PeachyDatePicker.Week>
                  </PeachyDatePicker.Body>
                </PeachyDatePicker.Table>
              </PeachyDatePicker.Calendar>
            </PeachyDialog.Target>
          </Teleport>
        </PeachyDialog.Dialog>
      </template>
    </PeachyDatePicker.Input>
  </PeachyInput.Input>
</template>
css
/* ===== Layout ===== */

.peachy-input.date {
  display: grid;
  width: 15rem;
  grid-template-columns: fit-content max-content;
  grid-template-areas: "label label" "input button";
  gap: var(--gap);
}

.peachy-input.date .peachy-input__label {
  grid-area: label;
}

.date-picker-button {
  grid-area: button;
}

.peachy-date-picker__input {
  grid-area: input;
}

/* ===== Text input ===== */

.peachy-date-picker__input {
  font: inherit;
  color: inherit;

  border-radius: 0.15rem;
  padding: 0.5rem;

  background-color: var(--bg);
  border: 1px solid var(--border);
  caret-color: var(--brand-color);

  transition: border-color 0.25s;

  min-width: 0;
}

.peachy-date-picker__input:not(:disabled):hover {
  border-color: var(--brand-color-dimmed);
}

.peachy-date-picker__input:focus-visible {
  outline: none !important;
  border-color: var(--brand-color);
}

/* ===== Button ===== */

.date-picker-button {
  border: none;
  cursor: pointer;

  height: 100%;
  background: var(--button-bg);

  aspect-ratio: 1;

  padding: var(--icon-button-padding) !important;
  border-radius: 0.15rem;

  transition: background 0.25s;
}

.date-picker-button:hover {
  background: var(--button-hover-bg);
}

.date-picker-button svg {
  width: var(--icon-size);
  height: var(--icon-size);
}

/* ===== Dialog ===== */

.date-picker-dialog {
  width: 23rem;
  padding: 1rem 0.75rem;

  margin: var(--gap) !important;

  border-radius: var(--border-radius);
  background: var(--bg);
  border: 1px solid var(--border);
  box-shadow: var(--shadow);
}

.date-picker-dialog[open]::backdrop {
  display: none;
}

.date-picker-navigation-wrapper {
  display: grid;
  grid-template-columns: max-content 1fr max-content;
}

.date-picker-navigation-wrapper svg {
  width: var(--icon-size);
}

.date-picker-navigation-wrapper svg.flip-icon {
  transform-origin: center;
  transform: rotate(180deg);
}

/* ===== Calendar meta ===== */

.peachy-date-picker__description {
  font-size: 100%;
  text-align: left;
  padding: 0.5rem;
  margin: 0.75rem 0 0.75rem 0;

  border-radius: var(--border-radius);
}

html.dark .peachy-date-picker__description {
  background-color: var(--dark-text);
}

html:not(.dark) .peachy-date-picker__description {
  border: 1px solid var(--border);
}

/* ===== Calendar Grid ===== */

.peachy-date-picker__table {
  width: 100%;
}

.peachy-date-picker__headings {
  border-bottom: 1px solid var(--divider);
}

.peachy-date-picker__heading {
  font-size: 100%;
  font-weight: var(--fw-bold);
}

.peachy-date-picker__heading {
  padding: 0.25rem;
  padding-bottom: 0.5rem;
}

.peachy-date-picker__week:first-of-type .peachy-date-picker__cell {
  padding-top: 0.5rem;
}

.peachy-date-picker__week-number {
  padding-right: 1rem;
}

.peachy-date-picker__cell {
  padding: 0.25rem;
}

.peachy-date-picker__day {
  font-size: 100%;

  min-width: 2rem;
  width: 2rem;
  max-width: 2rem;

  min-height: 2rem;
  height: 2rem;
  max-height: 2rem;

  border-radius: calc(var(--border-radius) / 2);
  background-color: var(--button-bg);
  color: var(--text);

  transition:
    transform 0.25s,
    background-color 0.25s,
    color 0.25s,
    filter 0.25s;
}

.peachy-date-picker__day[aria-pressed="true"] {
  background-color: var(--brand-button-bg);
  color: var(--brand-button-text);
}

.peachy-date-picker__day[data-active="true"] {
  outline: none !important;
  transform: scale(1.25);
}

html.dark .peachy-date-picker__day:focus-visible:not([aria-pressed="true"]),
html.dark .peachy-date-picker__day:hover:not([aria-pressed="true"]) {
  filter: brightness(150%);
}

html:not(.dark)
  .peachy-date-picker__day:focus-visible:not([aria-pressed="true"]),
html:not(.dark) .peachy-date-picker__day:hover:not([aria-pressed="true"]) {
  filter: brightness(80%);
}

.peachy-date-picker__title {
  width: 100%;
  text-align: center;
  font-weight: var(--fw-bold);
}

Anatomy

vue
<template>
  <PeachyInput.Input>
    <PeachyInput.Label />

    <PeachyDatePicker.Input>
      <!-- #before or #after-->
      <template #after>
        <PeachyDialog.Dialog>
          <PeachyDialog.Trigger />

          <PeachyDialog.Target>
            <PeachyDialog.Title />

            <PeachyDatePicker.Calendar>
              <PeachyDatePicker.Title />

              <PeachyDatePicker.Table>
                <PeachyDatePicker.Description />

                <!-- Heading row -->
                <PeachyDatePicker.Head>
                  <PeachyDatePicker.Headings>
                    <PeachyDatePicker.Heading />
                  </PeachyDatePicker.Headings>
                </PeachyDatePicker.Head>

                <!-- Rows -->
                <PeachyDatePicker.Body>
                  <PeachyDatePicker.Week>
                    <PeachyDatePicker.WeekNumber />
                    <PeachyDatePicker.Day />
                  </PeachyDatePicker.Week>
                </PeachyDatePicker.Body>
              </PeachyDatePicker.Table>
            </PeachyDatePicker.Calendar>
          </PeachyDialog.Target>
        </PeachyDialog.Dialog>
      </template>
    </PeachyDatePicker.Input>
  </PeachyInput.Input>
</template>

<script lang="ts" setup>
  import { PeachyInput, PeachyDatePicker, PeachyDialog } from "typeach";
</script>

You can use the props closeOnOutsideClick and placement for <PeachyDialog.Dialog /> to make the dialog act more similar to a popover.

Props & Emits

Input


Props

NameDefaultTypeDescription
dateDayJs?
inputFormats["M/D/YYYY", "MM/DD/YYYY"]string[]?Formats allows for the input, must be a valid Day.js formatting pattern.
displayFormat"M/D/YYYY"string?How the text in the input is formatted after date is updated, must be a valid Day.js formatting pattern.
disabledfalseboolean?
readonlyfalseboolean?
requiredfalseboolean?The component only labels it as required with aria-required and does not deal with any error messages for you.

Emits

@Payload
update:dateDayJs?
validateDayJs?
clear-validation

Calendar

DISABLED OR READONLY

There is no disabled or readonly state for the Calendar. You're expected to disable the <PeachyDialog.Trigger /> for both cases, as the input gives enough context

The date here refers to the focused date, not necessarily the selected date. This is the date you should use for any navigation buttons, and for updating the title of the calendar. See the example.

Props

NameDefaultType
dateDayJs()DayJs?

Emits

@Payload
update:dateDayJs?

Day


Props

NameDefaultTypeDescription
dateDayJs
selectedboolean?The calendar will deal with this for you - but if you're doing a range date picker this can be used to mark additional dates as selected.
disabledboolean?

Emits

@Payload
update:dateDayJs?

Styling

CSS Selectors

Day RENDERS A WRAPPER TABLE CELL

The Day element renders a table cell around the actual day button.

Follows our CSS classes convention, with some extras:

  • .peachy-date-picker__cell for the table cell element inside Day.

State selectors

SelectorDescriptionFor
[aria-pressed="<boolean>"]For a selected date.
  • Day
[data-active="<boolean>"]For a focused date.
  • Day
:disabledFor a disabled input.
  • Input
:read-onlyFor a read-only input.
  • Input

Accessibility

Resources: APG Date Picker Dialog, Duet's Date Picker, Deque University's Date Picker - see below for a more comprehensive list.

NVDA REQUIRES FOCUS MODE ENABLED TO NAVIGATE AS EXPECTED

When entering a table in NVDA, it goes into browse mode for most users. For a better experience, focus mode should be enabled.

To make users with screen readers aware of this, in the example we use <PeachyVisuallyHidden /> to let them know that if they're using NVDA, we recommend turning on focus mode.

Keyboard interactions

KeyAction
Space or on a DaySelects the date.
on a DayMoves focus to the next day.
on a DayMoves focus to the previous day..
on a DayMoves focus to the same day of the next week.
on a DayMoves focus to the same day of the previous week.
Home on a WeekMoves focus to the first day of the week.
End on a WeekMoves focus to the last day of the week.
PageUp on a WeekMoves focus to the same day in the previous month. If that day does not exist, moves focus to the last day of that month.
PageDown on a WeekMoves focus to the same day in the next month. If that day does not exist, moves focus to the last day of that month.
Shift + PageUp on a WeekMoves focus to the same day in the previous year. If that day does not exist, moves focus to the last day of that month.
Shift + PageDown on a WeekMoves focus to the same day in the next year. If that day does not exist, moves focus to the last day of that month.

Hover and keyboard interactions are synced.

Resources

Day.js

This component uses Day.js, with the plugins localeData, weekOfYear and customParseFormat.

COMPONENT DOES NOT RE-RENDER ON DAYJS CHANGES

If you make changes to dayjs dynamically (such as locale), you have to manually re-render your date picker wrapper component to get it to update.

If you have your owndayjs instance

  • Add the plugins localeData, weekOfYear and customParseFormat to it.
  • Customize our instance to match your configuration.
ts
import { dayJs } from "typeach";

import nb from "dayjs/locale/nb";
import localeData from "dayjs/plugin/localeData";
import weekPlugin from "dayjs/plugin/weekOfYear";
import customParseFormatPlugin from "dayjs/plugin/customParseFormat";

yourDayJsInstance.locale(nb);
yourDayJsInstance.extend(localeData);
yourDayJsInstance.extend(weekPlugin);
yourDayJsInstance.extend(customParseFormatPlugin);

dayJs.locale(nb);

Customizing

You can customize our dayjs instance as you like.

ts
import { dayJs } from "typeach";

import nb from "dayjs/locale/nb";

dayjs.locale(nb);

Creating a range date picker

vue
<template>
  <PeachyInput.Group>
    <PeachyInput.GroupLabel>Select a range</PeachyInput.GroupLabel>

    <div class="date-range-picker-wrapper">
      <DatePickerForRange
        v-model:date="startDate"
        :end-date="endDate"
        :start-date="startDate"
        is-for="start"
        label="Start date"
        description="Pick a start date."
        @update:date="startDate = $event" />

      <div class="date-range-arrow-wrapper">
        <LongArrowSvg aria-hidden class="arrow" />
      </div>

      <DatePickerForRange
        v-model:date="endDate"
        :end-date="endDate"
        :start-date="startDate"
        is-for="end"
        label="End date"
        description="Pick an end date." />
    </div>
  </PeachyInput.Group>
</template>
css
.peachy-input__group {
  border: none;
}

.peachy-input__group-label {
  font-weight: var(--fw-bold);
}

.date-range-picker-wrapper {
  display: grid;
  gap: calc(var(--gap) * 2);
  grid-template-columns: 1fr auto 1fr;
  justify-content: center;
}

.date-range-picker-wrapper .peachy-input.date {
  grid-template-columns: auto min-content;
  grid-template-areas: "error error" "input button";
}

.peachy-date-picker__input {
  grid-area: input;
}

.peachy-dialog__trigger.date-picker-button {
  grid-area: button;
}

.date-range-arrow-wrapper {
  display: grid;
  place-content: end;
  flex-grow: 1;
}

.date-range-picker-wrapper .arrow {
  margin-bottom: 0.5rem;
  height: var(--icon-size);
  width: var(--icon-size);
}

.date-range-picker-wrapper .peachy-input__error {
  grid-area: error;
  font-size: 90%;
  opacity: 0.9;
}

.peachy-date-picker__day[aria-disabled="true"] {
  opacity: 0.5;
  pointer-events: none;
}

.peachy-date-picker__day[aria-disabled="true"][aria-pressed="true"] {
  background-color: var(--error-color);
}

.peachy-date-picker__input[aria-invalid="true"] {
  caret-color: var(--error-color);
  border-color: var(--error-color);
}

.peachy-date-picker__input[aria-invalid="true"]:not(:disabled):hover {
  border-color: var(--error-color);
}

.peachy-date-picker__input[aria-invalid="true"]:focus,
.peachy-date-picker__input[aria-invalid="true"]
  .peachy-date-picker__input:focus-visible {
  outline: none !important;
  border-color: var(--error-color);
}

@media only screen and (max-width: 45rem) {
  .date-range-picker-wrapper {
    grid-template-columns: 1fr;
  }

  .date-picker-inputs-wrapper {
    width: 15rem;
  }

  .date-range-arrow-wrapper {
    place-content: start;
  }

  .arrow {
    margin-top: 1rem;
    transform-origin: center;
    transform: rotate(90deg);
  }
}

Making <DatePickerForRange />

The DatePickerForRange component above is a placeholder name for whatever you name your date picker wrapper for working with ranges, and not a component from the library.

The component has some key elements to help us out:

  • a is-for prop to set this as "start" or "end" date picker
  • a v-model for the date
  • a startDate prop and an endDate prop

Inside our date picker for a range, we set our days to selected or not in the template using the selected prop, and we disable days depending on the isFor prop.

ENSURING ATTRIBUTES STAY UP TO DATE

We're explicitly passing startDate and endDate to the functions, rather than accessing props.startDate and props.endDate inside them - this is to ensure they update when either of the values change.

vue
<template>
  <!-- ... -->

  <PeachyDatePicker.Day
    v-for="day in weeks[parseInt(week)]"
    :key="day.get('date')"
    :date="day"
    :selected="isSelected(day, startDate, endDate)"
    :disabled="isFor === 'start' ? isAfter(day, endDate) : isBefore(day, startDate)">
    <span aria-hidden="true">
      {{ day.format("D") }}
    </span>

    <PeachyVisuallyHidden>
      {{ day.format("D MMM YYYY") }}
    </PeachyVisuallyHidden>
  </PeachyDatePicker.Day>

  <!-- ... -->
</template>

And here are the functions used.

ts
const isBefore = (date: DayJs, startDate?: DayJs) => {
  if (!startDate) {
    return false;
  }

  return date.isBefore(startDate);
};

const isAfter = (date: DayJs, endDate?: DayJs) => {
  if (!endDate) {
    return false;
  }

  return date.isAfter(endDate);
};

const isStartOrEndDate = (
  date: DayJs,
  startDate: DayJs | undefined,
  endDate: DayJs | undefined
) => {
  const formattedDate = date.format("MM/DD/YYYY");

  const formattedStartDate = startDate?.format("MM/DD/YYYY");
  const formattedEndDate = endDate?.format("MM/DD/YYYY");

  return [formattedStartDate, formattedEndDate].includes(formattedDate);
};

const isSelected = (
  date: DayJs,
  startDate: DayJs | undefined,
  endDate: DayJs | undefined
) => {
  if (isStartOrEndDate(date, startDate, endDate)) {
    return true;
  }

  if (!startDate || !endDate) {
    return false;
  }

  return date.isAfter(startDate) && date.isBefore(endDate);
};

Invalid dates selected

Above we've made sure the calendar dropdown doesn't allow selecting an invalid date by disabling them. However, this does nothing to prevent the user from entering an invalid date in either of the text input.

For that case, you should provide error messages as with any other <Input />. We trigger the validation on the date picker's Input event @validate - but also whenever start or end date changes.

ts
const validationError = ref<string | undefined>();

const validate = (date?: DayJs) => {
  if (!date) {
    validationError.value = undefined;
    return;
  }

  if (props.isFor === "start" && isAfter(date, props.endDate)) {
    validationError.value = "Can not be after end date.";
  } else if (props.isFor === "end" && isBefore(date, props.startDate)) {
    validationError.value = "Can not be before start date.";
  } else {
    validationError.value = undefined;
  }
};

watch(toRef(props, "startDate"), () => {
  if (props.isFor === "end") {
    validate(dateRef.value);
  }
});

watch(toRef(props, "endDate"), () => {
  if (props.isFor === "start") {
    validate(dateRef.value);
  }
});