Date and time picker
One field for a day and a time: one trigger, one popover, one Date out.
<script setup lang="ts">
import SaDateTimePicker from '@shared/components/common/SaDateTimePicker.vue'
import { ref } from 'vue'
const start = ref<Date | undefined>(new Date(2026, 4, 12, 14, 0))
</script>
<template>
<div class="max-w-sm space-y-3">
<SaDateTimePicker v-model="start" />
<p class="text-sm text-muted-foreground">
ISO value: {{ start ? start.toISOString() : 'empty' }}
</p>
</div>
</template>Usage
v-model is a Date, or undefined for no value. Two separate boxes would ask whoever wrote the form to recombine them on submit, which is how bookings end up in the wrong hour.
The trigger prints the value in the app's language, the date and the time joined by a space, so it reads like the table cell it edits.
Picking a day keeps the time already set. Picking a time before any day has been chosen lands on today, so the press has a visible result the reader can correct, rather than being dropped.
The time columns follow the language the same way the time picker does: 1–12 with AM/PM where the language writes a 12-hour clock, 0–23 and no third column where it does not.
Examples
Empty
<script setup lang="ts">
import SaDateTimePicker from '@shared/components/common/SaDateTimePicker.vue'
import { ref } from 'vue'
const published = ref<Date | undefined>()
</script>
<template>
<div class="max-w-sm">
<SaDateTimePicker v-model="published" placeholder="Publish immediately" />
</div>
</template>Steps and a starting hour
minuteStep thins the minute column. defaultHour is the hour used when a day is picked before any time; it is a literal rather than "now", so the control renders the same every time.
<script setup lang="ts">
import SaDateTimePicker from '@shared/components/common/SaDateTimePicker.vue'
import { ref } from 'vue'
const slot = ref<Date | undefined>()
</script>
<template>
<div class="max-w-sm">
<SaDateTimePicker
v-model="slot"
:minute-step="30"
:default-hour="13"
placeholder="Pick an appointment"
/>
</div>
</template>Disabled
<script setup lang="ts">
import SaDateTimePicker from '@shared/components/common/SaDateTimePicker.vue'
import { ref } from 'vue'
const closedAt = ref<Date | undefined>(new Date(2026, 1, 3, 10, 45))
</script>
<template>
<div class="max-w-sm">
<SaDateTimePicker v-model="closedAt" disabled />
</div>
</template>API
Source: src/shared/components/common/SaDateTimePicker.vue
Props
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | Date | undefined | required | The value. An invalid Date is treated as no value. |
id | string | — | Put on the trigger, so a <Label for> points at it. |
placeholder | string | 'common.pickADateAndTime' | The trigger text while there is no value. The default renders as "Pick a date and time". |
disabled | boolean | false | Blocks the trigger. |
invalid | boolean | false | Sets aria-invalid on the trigger. |
required | boolean | — | Sets aria-required on the trigger. |
describedBy | string | — | Sets aria-describedby on the trigger. |
minuteStep | number | 1 | The step between offered minutes. The value's own minute is always offered too. |
defaultHour | number | 9 | The hour used when a day is picked and no time is set yet. |
Emits
| Event | Payload | Description |
|---|---|---|
update:modelValue | value: Date | undefined | Fires on a day press and on every hour, minute or AM/PM press. Seconds and milliseconds are set to zero. |
Slots
None.
Accessibility
- The calendar is the shared calendar, so the week starts on the day the app's language starts on, and the grid answers to the arrow keys,
Home,End,PageUpandPageDown. - Each time column is a
listbox, named "Hour", "Minute" and "AM or PM", and every cell is anoptioncarryingaria-selected. - Opening the popover scrolls the columns to the selected rows.
- The trigger takes
aria-invalid,aria-requiredandaria-describedbyfrom the props of the same names. - A Done button closes the popover, so the interaction can be finished from the keyboard without hunting for the trigger.