A segmented date / time input. Each unit — year, month, day, hour, minute,
AM/PM — is its own focusable segment; separators are static, and the container
shows a single focus ring via :focus-within. Type digits to fill a segment (it
auto-advances when no further digit fits), use ↑/↓ to step
a value (stepping an empty segment starts from the current date/time), and
←/→ to move between segments. Date segment order and
separators follow the locale.
One component covers three shapes via granularity: "date" (default),
"time", and "datetime". Set range for a start/end pair. The time portion
follows the locale’s 12/24-hour cycle unless you force it with is24Hour.
The value is a Date (or a { start, end } DateRange when range is set),
exposed as a bindable value + onValueChange with defaultValue for uncontrolled
use, and serialized into hidden input(s) for native form submission —
YYYY-MM-DD for date, HH:MM for time, YYYY-MM-DDTHH:MM for datetime.
Opt into a calendar picker with calendar: it adds a trailing calendar-icon
button (joined to the field with a controls Group) that opens a date-picker
popover, so the same field supports both typing and point-and-click selection.
value: (null)
---
// GENERATED from packages/demos/input-datetime/default.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const readoutClass = css({
fontFamily: "monospace",
fontSize: "0.75rem",
opacity: 0.7,
});
const value = null as Date | null;
---
<div class={stack({ gap: "md" })} data-ir-root="input-datetime/default">
<InputDatetime value={null as Date | null} data-ir="1" />
<p class={readoutClass}>
value: <!--ir:2-->
{value?.toISOString().slice(0, 10) ?? "(null)"}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/default.ts";
document
.querySelectorAll('[data-ir-root="input-datetime/default"]')
.forEach((root) => hydrate([root]));
</script>granularity="time" renders hour + minute, plus an AM/PM segment in 12-hour
mode. is24Hour forces the 24-hour clock.
value: (null)
---
// GENERATED from packages/demos/input-datetime/time.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const pad2 = (n: number) => (n < 10 ? `0${n}` : `${n}`);
const hhmm = (d: Date | null) =>
d ? `${pad2(d.getHours())}:${pad2(d.getMinutes())}` : "(null)";
const captionClass = css({ fontSize: "0.75rem", opacity: 0.7 });
const readoutClass = css({
fontFamily: "monospace",
fontSize: "0.75rem",
opacity: 0.7,
});
const value = null as Date | null;
---
<div class={stack({ gap: "md" })} data-ir-root="input-datetime/time">
<label class={stack({ gap: "2xs" })}>
<span class={captionClass}>12-hour (AM/PM)</span>
<InputDatetime
granularity="time"
fullWidth={false}
value={null as Date | null}
data-ir="1"
/>
</label>
<label class={stack({ gap: "2xs" })}>
<span class={captionClass}>24-hour</span>
<InputDatetime granularity="time" is24Hour fullWidth={false} />
</label>
<p class={readoutClass}>
value: <!--ir:2-->
{hhmm(value)}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/time.ts";
document
.querySelectorAll('[data-ir-root="input-datetime/time"]')
.forEach((root) => hydrate([root]));
</script>granularity="datetime" composes the date and time segments into one input.
value: (null)
---
// GENERATED from packages/demos/input-datetime/datetime.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const readoutClass = css({
fontFamily: "monospace",
fontSize: "0.75rem",
opacity: 0.7,
});
const value = null as Date | null;
---
<div class={stack({ gap: "md" })} data-ir-root="input-datetime/datetime">
<InputDatetime
granularity="datetime"
value={null as Date | null}
data-ir="1"
/>
<p class={readoutClass}>
value: <!--ir:2-->
{value?.toISOString().slice(0, 16) ?? "(null)"}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/datetime.ts";
document
.querySelectorAll('[data-ir-root="input-datetime/datetime"]')
.forEach((root) => hydrate([root]));
</script>calendar adds a trailing calendar-icon trigger that opens a date-picker
popover. The calendar selects the date part; for granularity="datetime"
the time stays typed in the segments (a missing time defaults to 00:00 on the
first pick). It is shown for granularity "date" and "datetime", and for
ranges — but never for "time" (a clock has no calendar). Picking a day fills
the field and closes the popover; typing keeps the open calendar in sync,
navigating to and highlighting the typed date.
value: (null)
---
// GENERATED from packages/demos/input-datetime/calendar.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const readoutClass = css({
fontFamily: "monospace",
fontSize: "0.75rem",
opacity: 0.7,
});
const value = null as Date | null;
---
<div class={stack({ gap: "md" })} data-ir-root="input-datetime/calendar">
<InputDatetime calendar value={null as Date | null} data-ir="1" />
<p class={readoutClass}>
value: <!--ir:2-->
{value?.toISOString().slice(0, 10) ?? "(null)"}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/calendar.ts";
document
.querySelectorAll('[data-ir-root="input-datetime/calendar"]')
.forEach((root) => hydrate([root]));
</script>Combine calendar with range for a two-click range picker. The first click
sets the start; the second commits { start, end } and closes the popover.
(null) → (null)
---
// GENERATED from packages/demos/input-datetime/calendar-range.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
import type { DateRange } from "@pindoba/core-input-datetime";
const iso = (d: Date | null) => (d ? d.toISOString().slice(0, 10) : "(null)");
const readoutClass = css({
fontFamily: "monospace",
fontSize: "0.75rem",
opacity: 0.7,
});
const value = { start: null, end: null } as DateRange;
---
<div class={stack({ gap: "md" })} data-ir-root="input-datetime/calendar-range">
<InputDatetime
range
calendar
value={{ start: null, end: null } as DateRange}
data-ir="1"
/>
<p class={readoutClass}>
<!--ir:2-->
{iso(value.start)}
<!--/ir--> → <!--ir:3-->
{iso(value.end)}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/calendar-range.ts";
document
.querySelectorAll('[data-ir-root="input-datetime/calendar-range"]')
.forEach((root) => hydrate([root]));
</script>calendarMonths shows more than one month in the popover — handy for ranges
that cross a month boundary. It maps to the Calendar’s numberOfMonths
(clamped to 1–3) and only applies with calendar.
---
// GENERATED from packages/demos/input-datetime/calendar-months.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
---
<InputDatetime range calendar calendarMonths={2} />With granularity="datetime", the calendar only sets the date — the typed time
of day is preserved (or defaults to 00:00 on the first pick). Type a time, pick
a day from the calendar, and the time stays put.
value: (null)
---
// GENERATED from packages/demos/input-datetime/calendar-datetime.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const readoutClass = css({
fontFamily: "monospace",
fontSize: "0.75rem",
opacity: 0.7,
});
const value = null as Date | null;
---
<div
class={stack({ gap: "md" })}
data-ir-root="input-datetime/calendar-datetime"
>
<InputDatetime
calendar
granularity="datetime"
value={null as Date | null}
data-ir="1"
/>
<p class={readoutClass}>
value: <!--ir:2-->
{value?.toISOString().slice(0, 16) ?? "(null)"}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/calendar-datetime.ts";
document
.querySelectorAll('[data-ir-root="input-datetime/calendar-datetime"]')
.forEach((root) => hydrate([root]));
</script>range renders two groups joined by a separator (configurable via
rangeSeparator). The value becomes { start, end }, and each side’s day
clamps to its own month/year.
(null) → (null)
---
// GENERATED from packages/demos/input-datetime/range.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
import type { DateRange } from "@pindoba/core-input-datetime";
const iso = (d: Date | null) => (d ? d.toISOString().slice(0, 10) : "(null)");
const readoutClass = css({
fontFamily: "monospace",
fontSize: "0.75rem",
opacity: 0.7,
});
const value = { start: null, end: null } as DateRange;
---
<div class={stack({ gap: "md" })} data-ir-root="input-datetime/range">
<InputDatetime
range
value={{ start: null, end: null } as DateRange}
data-ir="1"
/>
<p class={readoutClass}>
<!--ir:2-->
{iso(value.start)}
<!--/ir--> → <!--ir:3-->
{iso(value.end)}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/range.ts";
document
.querySelectorAll('[data-ir-root="input-datetime/range"]')
.forEach((root) => hydrate([root]));
</script>Date segment order and separators are derived from the locale via the
platform’s Intl short-date pattern. Use separator to override the date-part
separator.
---
// GENERATED from packages/demos/input-datetime/formats.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const seed = new Date(2026, 4, 14);
const captionClass = css({ fontSize: "0.75rem", opacity: 0.7 });
---
<div class={stack({ gap: "md" })}>
<label class={stack({ gap: "2xs" })}>
<span class={captionClass}>en-US — MM/DD/YYYY</span>
<InputDatetime locale="en-US" defaultValue={seed} fullWidth={false} />
</label>
<label class={stack({ gap: "2xs" })}>
<span class={captionClass}>en-GB — DD/MM/YYYY</span>
<InputDatetime locale="en-GB" defaultValue={seed} fullWidth={false} />
</label>
<label class={stack({ gap: "2xs" })}>
<span class={captionClass}>pt-BR — DD/MM/YYYY</span>
<InputDatetime locale="pt-BR" defaultValue={seed} fullWidth={false} />
</label>
</div>Every accessible name the field renders comes from labels. With a non-English
locale, the segment names are derived from Intl.DisplayNames (pt-BR →
Ano / Mês / Dia); everything else keeps its English default until you pass
it. The popover dialog is named labels.calendarDialog (default
"Choose date", or labels.calendarDialogRange → "Choose date range" with
range), and the trigger button labels.calendarTrigger.
The popover calendar receives the field’s locale, so its weekdays, month
names, view tabs and Today button follow it automatically. Its remaining
strings are forwarded through labels.calendar, which takes the same keys as
the Calendar’s own labels prop:
const labels = {
calendarTrigger: "Escolher data",
calendarDialog: "Escolher data",
calendar: {
previous: "Anterior",
next: "Próximo",
reset: "Limpar",
dayToday: "{date}, hoje",
},
};
On Astro, use {placeholder} template strings (not functions) inside
labels.calendar — see the Calendar’s
Localization & labels section.
required surfaces an error when the field is left empty on blur — data-error
flips the input to the danger palette and emits onErrorChange (Svelte) /
pindoba:invalid (Astro).
error: (none)
---
// GENERATED from packages/demos/input-datetime/required.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
import type { InputDatetimeError } from "@pindoba/core-input-datetime";
const readoutClass = css({
fontFamily: "monospace",
fontSize: "0.75rem",
opacity: 0.7,
});
const error = null as InputDatetimeError | null;
---
<div class={stack({ gap: "md" })} data-ir-root="input-datetime/required">
<InputDatetime required value={null as Date | null} data-ir="1" />
<p class={readoutClass}>
error: <!--ir:2-->
{error ?? "(none)"}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/required.ts";
document
.querySelectorAll('[data-ir-root="input-datetime/required"]')
.forEach((root) => hydrate([root]));
</script>disabled greys the field, drops the segments out of the tab order, and ignores
all editing — in both the Svelte and Astro (DOM-attach) paths.
---
// GENERATED from packages/demos/input-datetime/disabled.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "md" })}>
<InputDatetime disabled defaultValue={new Date(2026, 4, 14)} />
</div>The component renders hidden <input>(s), so it submits inside a native
<form> like any other control — name is the field key (range adds
${name}-end).
---
// GENERATED from packages/demos/input-datetime/form.demo.tsx — edit the source, not this file.
import InputDatetime from "@pindoba/astro-input-datetime";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const submitClass = css({
alignSelf: "flex-start",
py: "0.25rem",
px: "0.75rem",
fontSize: "0.875rem",
});
const resultClass = css({
fontFamily: "monospace",
fontSize: "0.75rem",
opacity: 0.7,
});
const submitted = "";
---
<form
class={stack({ gap: "md" })}
data-ir-root="input-datetime/form"
data-ir="1"
>
<InputDatetime name="appointment" defaultValue={new Date(2026, 4, 14)} />
<button type="submit" class={submitClass}>
Submit
</button>
<p data-submitted class={resultClass}>
submitted: <!--ir:2-->
{submitted || "(nothing yet)"}
<!--/ir-->
</p>
</form>
<script>
import { hydrate } from "./_client/form.ts";
document
.querySelectorAll('[data-ir-root="input-datetime/form"]')
.forEach((root) => hydrate([root]));
</script>