Scheduling
A booking is two values, not one. DatePicker holds the day and TimeField holds the clock time, each with its own segments and its own keyboard; joining them into a single instant is the screen's job, and it happens once, at the point something actually needs an instant.
import { DatePicker, FieldGroup, TimeField } from "ropav";Tuesday, October 6, 2026 at 9:30 AM
scheduling.vue
<script setup lang="ts">
import type { DateValue } from "@internationalized/date";
import {
getLocalTimeZone,
isWeekend,
Time,
toCalendarDateTime,
today,
} from "@internationalized/date";
import {
Calendar,
CalendarCell,
CalendarGrid,
CalendarGridBody,
CalendarGridHeader,
CalendarHeader,
CalendarHeaderCell,
CalendarNavButton,
DatePicker,
DatePickerGroup,
DatePickerInput,
DatePickerPopover,
DatePickerSegment,
DatePickerSuffix,
DatePickerTrigger,
DatePickerTriggerIndicator,
Description,
FieldGroup,
Label,
TimeField,
TimeFieldGroup,
TimeFieldInput,
TimeFieldSegment,
useLocale,
} from "ropav";
import { computed, shallowRef } from "vue";
const zone = getLocalTimeZone();
const locale = useLocale();
const earliest = today(zone);
const date = shallowRef<DateValue | null>(earliest.add({ days: 1 }));
const time = shallowRef<Time | null>(new Time(9, 30));
/* Which days are the weekend is a property of the locale, not of the calendar. */
const isDateUnavailable = (value: DateValue) => isWeekend(value, locale.value.locale);
/*
* A date and a time are separate values and stay that way until something needs one instant. The
* screen is what joins them, because only the screen knows they belong to the same booking.
*/
const booking = computed(() =>
date.value && time.value ? toCalendarDateTime(date.value, time.value) : null,
);
const formatted = computed(() =>
booking.value
? booking.value.toDate(zone).toLocaleString(locale.value.locale, {
dateStyle: "full",
timeStyle: "short",
})
: "Pick a weekday and a time.",
);
</script>
<template>
<div class="screen">
<FieldGroup>
<DatePicker
v-model:value="date"
is-required
:is-date-unavailable="isDateUnavailable"
:min-value="earliest"
name="day"
>
<Label>Day</Label>
<DatePickerGroup full-width>
<DatePickerInput>
<template #default="{ segment }">
<DatePickerSegment :segment />
</template>
</DatePickerInput>
<DatePickerSuffix>
<DatePickerTrigger>
<DatePickerTriggerIndicator />
</DatePickerTrigger>
</DatePickerSuffix>
</DatePickerGroup>
<Description>Weekends and anything before today are not bookable.</Description>
<DatePickerPopover>
<Calendar aria-label="Booking day">
<CalendarHeader>
<CalendarNavButton slot="previous" />
<CalendarNavButton slot="next" />
</CalendarHeader>
<CalendarGrid>
<CalendarGridHeader>
<template #default="{ day }">
<CalendarHeaderCell>{{ day }}</CalendarHeaderCell>
</template>
</CalendarGridHeader>
<CalendarGridBody>
<template #default="{ date: cell }">
<CalendarCell :date="cell" />
</template>
</CalendarGridBody>
</CalendarGrid>
</Calendar>
</DatePickerPopover>
</DatePicker>
<TimeField v-model:value="time" is-required name="start">
<Label>Start</Label>
<TimeFieldGroup>
<TimeFieldInput>
<template #default="{ segment }">
<TimeFieldSegment :segment />
</template>
</TimeFieldInput>
</TimeFieldGroup>
</TimeField>
</FieldGroup>
<p class="result">{{ formatted }}</p>
</div>
</template>
<style scoped>
.screen {
display: flex;
max-width: var(--rp-container-sm);
flex-direction: column;
gap: calc(var(--rp-spacing) * 5);
}
.result {
padding-top: calc(var(--rp-spacing) * 4);
border-top: 1px solid var(--rp-border);
color: var(--rp-muted);
font-size: var(--rp-text-sm);
line-height: var(--rp-text-sm--line-height);
}
</style>The values come from the date package
A date field does not hold a Date. It holds a calendar value — a year, a month and a day with no timezone attached — which is what lets the same field work in a calendar that is not Gregorian. today, Time and toCalendarDateTime come from the same package the library reads them with, and calendar systems is where the rest of it is set out.
Refusing a day, twice
min-value and is-date-unavailable do two different jobs. min-value bounds the range the segments will even count to; is-date-unavailable answers per day, which is how a weekend is refused without refusing the days around it. Both mark the field invalid rather than silently correcting it, so a value typed into the segments is judged the same way as one picked from the calendar.
Which days are the weekend is a locale question
isWeekend takes a locale because the answer changes with it — Saturday and Sunday in much of the world, Friday and Saturday in much of the Middle East. useLocale returns the one in force below the nearest provider, falling back to the browser's, so the rule follows the reader rather than the author.
Accessibility
- A date field is a group of segments, each its own stop with the arrow keys stepping its value. It is not a text input, and nothing has to be parsed to be understood.
- The calendar in the popover takes an
aria-label. The field's own label names the field, not the grid that opened over it. - An unavailable day is marked as such rather than removed, so a reader arrowing across the grid is told why it cannot be chosen instead of finding a gap.