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.
<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>
/* ===== 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
<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
Name | Default | Type | Description |
---|---|---|---|
date | DayJs? | ||
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. |
disabled | false | boolean? | |
readonly | false | boolean? | |
required | false | boolean? | The component only labels it as required with aria-required and does not deal with any error messages for you. |
Emits
@ | Payload |
---|---|
update:date | DayJs? |
validate | DayJs? |
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
Name | Default | Type |
---|---|---|
date | DayJs() | DayJs? |
Emits
@ | Payload |
---|---|
update:date | DayJs? |
Day
Props
Name | Default | Type | Description |
---|---|---|---|
date | DayJs | ||
selected | boolean? | 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. | |
disabled | boolean? |
Emits
@ | Payload |
---|---|
update:date | DayJs? |
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 insideDay
.
State selectors
Selector | Description | For |
---|---|---|
[aria-pressed="<boolean>"] | For a selected date. |
|
[data-active="<boolean>"] | For a focused date. |
|
:disabled | For a disabled input. |
|
:read-only | For a read-only 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
Key | Action |
---|---|
Space or ↵ on a Day | Selects the date. |
→ on a Day | Moves focus to the next day. |
← on a Day | Moves focus to the previous day.. |
↓ on a Day | Moves focus to the same day of the next week. |
↑ on a Day | Moves focus to the same day of the previous week. |
Home on a Week | Moves focus to the first day of the week. |
End on a Week | Moves focus to the last day of the week. |
PageUp on a Week | Moves 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 Week | Moves 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 Week | Moves 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 Week | Moves 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
APG
DigitalAlly
Fymmot - Inclusive Dates
WebAxe - Accessible Date Pickers
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
andcustomParseFormat
to it. - Customize our instance to match your configuration.
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.
import { dayJs } from "typeach";
import nb from "dayjs/locale/nb";
dayjs.locale(nb);
Creating a range date picker
<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>
.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 anendDate
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.
<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.
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.
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);
}
});