Skip to content

Date · optional time

Sometimes a field is "a date, and maybe a time" — a due date that's usually just a day but occasionally needs an hour, a reminder that can be all-day or timed. Instead of a separate checkbox, these pickers put a small clock toggle beside the field: off means a plain date, on means a date with time.

Two variants, by whether the time carries a timezone:

ts
import {
  CoarZonedDateTimeOrDatePicker, // PlainDate  ⇄  ZonedDateTime (with IANA zone)
  CoarPlainDateTimeOrDatePicker, // PlainDate  ⇄  PlainDateTime (zone-less)
} from '@cocoar/vue-ui';

Basic usage

Click the clock to switch modes. The value is one of two Temporal types — read it directly to know what you got.

date only → null
vue
<template>
  <div style="display: flex; flex-direction: column; gap: 12px; max-width: 420px;">
    <CoarFormField label="Due">
      <CoarZonedDateTimeOrDatePicker v-model="value" v-model:with-time="withTime" clearable />
    </CoarFormField>
    <span style="font-size: 13px; color: #64748b;">
      {{ withTime ? 'with time' : 'date only' }} →
      <code>{{ value ? `${value.constructor.name}: ${value.toString()}` : 'null' }}</code>
    </span>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { CoarZonedDateTimeOrDatePicker, CoarFormField } from '@cocoar/vue-ui';
import type { Temporal } from '@js-temporal/polyfill';

// One value, two shapes: a PlainDate while the clock is off, a ZonedDateTime when on.
const value = ref<Temporal.ZonedDateTime | Temporal.PlainDate | null>(null);
const withTime = ref(false);
</script>

The value model

A single v-model carries the union value, and v-model:withTime is the toggle. They stay in sync: a non-null value's own type is the mode, and while empty the toggle decides.

vue
<CoarZonedDateTimeOrDatePicker v-model="value" v-model:with-time="withTime" />
ts
// value is Temporal.ZonedDateTime | Temporal.PlainDate | null
if (value instanceof Temporal.ZonedDateTime) {
  // time was on — persist a timestamp
} else if (value instanceof Temporal.PlainDate) {
  // date only — persist a calendar date
}

Toggling on keeps the date and adds a time (the current time, rounded to minuteStep) — and, for the zoned variant, the timeZone (or the user's zone). Toggling off drops the time (and zone) back to the date. So flipping the clock never loses the day.

Zone-less variant

CoarPlainDateTimeOrDatePicker is the same idea without timezones — the with-time value is a Temporal.PlainDateTime.

date only → null
vue
<template>
  <div style="display: flex; flex-direction: column; gap: 12px; max-width: 420px;">
    <CoarFormField label="Reminder">
      <CoarPlainDateTimeOrDatePicker v-model="value" v-model:with-time="withTime" clearable />
    </CoarFormField>
    <span style="font-size: 13px; color: #64748b;">
      {{ withTime ? 'with time' : 'date only' }} →
      <code>{{ value ? `${value.constructor.name}: ${value.toString()}` : 'null' }}</code>
    </span>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { CoarPlainDateTimeOrDatePicker, CoarFormField } from '@cocoar/vue-ui';
import type { Temporal } from '@js-temporal/polyfill';

// The zone-less variant: PlainDate while off, PlainDateTime when on.
const value = ref<Temporal.PlainDateTime | Temporal.PlainDate | null>(null);
const withTime = ref(false);
</script>

Toggle position & sizes

The clock sits to the left by default; set toggle-position="end" for the right. Sizes match the rest of the form-control family (xs · s · m · l), and the toggle scales with the field.

xs
s
m
l
Toggle position: start (default) — use toggle-position="end" to put the clock on the right.
vue
<template>
  <div style="display: flex; flex-direction: column; gap: 16px; max-width: 460px;">
    <div v-for="s in sizes" :key="s" style="display: flex; align-items: center; gap: 12px;">
      <code style="width: 24px; font-size: 12px; color: #64748b;">{{ s }}</code>
      <CoarZonedDateTimeOrDatePicker v-model="value" v-model:with-time="withTime" :size="s" />
    </div>
    <span style="font-size: 13px; color: #64748b;">Toggle position: <code>start</code> (default) — use <code>toggle-position="end"</code> to put the clock on the right.</span>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { CoarZonedDateTimeOrDatePicker, type CoarZonedDateTimeOrDatePickerSize } from '@cocoar/vue-ui';
import type { Temporal } from '@js-temporal/polyfill';

const sizes: CoarZonedDateTimeOrDatePickerSize[] = ['xs', 's', 'm', 'l'];
const value = ref<Temporal.ZonedDateTime | Temporal.PlainDate | null>(null);
const withTime = ref(false);
</script>

Composition, not reinvention

These are thin wrappers over CoarPlainDatePicker, CoarPlainDateTimePicker and CoarZonedDateTimePicker — they render the existing picker for the active mode and add the toggle beside it. All the common props pass straight through.

Theming

There's no new chrome to theme: the field is a CoarInputFrame and the toggle a CoarButton, both driven by the usual tokens. The toggle's roundness tracks --coar-input-radius, so it always matches the adjacent field (a pill theme gives a round toggle), and the gap uses --coar-spacing-xs. Nothing to register in the theme editor — tuning the existing input / button / spacing / accent tokens restyles these too.

Accessibility

The clock is a toggle button: it exposes aria-pressed for its state and a state-aware tooltip + aria-label (Add time when off, Remove time when on, both overridable). The active field itself is the underlying picker, so its keyboard and screen-reader behaviour is identical to the standalone pickers.

Wrap the picker in a CoarFormField for a label, the required asterisk, validation message and status icon — exactly like the other date pickers. The field auto-adopts the form field's id and error state.

API

Both components share this surface (the zoned variant adds the timezone props).

Props

PropTypeDefaultDescription
v-modelPlainDate | ZonedDateTime | null (zoned)
PlainDate | PlainDateTime | null (plain)
nullThe union value.
v-model:withTimebooleanfalseThe clock toggle / mode.
placeholderstring''Placeholder text.
size'xs' | 's' | 'm' | 'l''m'Field + toggle size.
disabledbooleanfalseDisable the field and toggle.
readonlybooleanfalseRead-only (toggle disabled too).
requiredbooleanfalseMark as required.
errorbooleanfalseError state. Auto-injected from a wrapping CoarFormField.
clearablebooleanfalseShow the inline clear button.
localestringLocale override.
min / maxwith-time typenullBounds; the date mode uses their date part.
minuteStep1 | 5 | 10 | 155Time-of-day step (also the default-time rounding).
togglePosition'start' | 'end''start'Clock side, relative to the field.
addTimeLabelstring'Add time'Tooltip + label while time is off.
removeTimeLabelstring'Remove time'Tooltip + label while time is on.

Zoned variant only:

PropTypeDefaultDescription
timeZonestring | nullnullDefault IANA zone for a value gaining a time (else the user's zone).
timezoneFilterstring[][]Wildcard filters for the timezone list (e.g. ['Europe/*']).

Events

EventPayloadDescription
update:modelValueunion valueThe value changed.
update:withTimebooleanThe mode changed.
opened / closedThe calendar panel opened / closed.

Released under the Apache-2.0 License.