Skip to main content
astrocraft-ui/ components · 101

Date Picker

Free · MIT

Headless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.

Install

Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).

Unstyled — behavior only, you write the CSS

Install command
npx astrocraft-ui add forms/date-picker

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add forms/date-picker --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add forms/date-picker --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/date-picker --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/date-picker --theme tailwind --bridge lumos

Live demo

DatePicker & DateRangePicker

A Calendar in a Popover over a real <input type="date">. Type into the field and open the panel — it is already on that month. Pick a day and the field fills, the panel closes and focus returns to the field. In the range picker, picking a day before the start becomes the new start rather than a backwards range, and the days between are markeddata-in-range.

Stay

Attribute surface

With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.

ComponentSlotsVariantsRuntime stateNative state
`DatePicker.astro``date-picker` `date-picker-input``data-size`: `sm` · `md` · `lg`
`data-state`: `default` · `error` · `success`
`data-week-start`: `0` · `1` · `2` · `3` · `4` · `5` · `6`
`data-side`: `bottom` · `top` · `left` · `right`
`data-align`: `start` · `center` · `end`
`data-value` `data-month`—
`DateRangePicker.astro``date-range-picker` `date-range-picker-end` `date-range-picker-separator` `date-range-picker-start``data-size`: `sm` · `md` · `lg`
`data-state`: `default` · `error` · `success`
`data-week-start`: `0` · `1` · `2` · `3` · `4` · `5` · `6`
`data-side`: `bottom` · `top` · `left` · `right`
`data-align`: `start` · `center` · `end`
`data-value` `data-month`—

Source

What the command copies — 3 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.

src/components/ui/forms/date-picker/DatePicker.astro
---
// src/components/ui/forms/date-picker/DatePicker.astro — headless primitive (see ../../README.md).
// A real <input type="date"> with a Calendar in a Popover beside it. The INPUT is the value: it is
// what carries `name`, what the form submits, what validates, and what a mobile browser opens its
// own native picker for. The calendar is an enhancement over it, not a replacement for it — delete
// the JavaScript and this is still a working date field, which is the whole reason it is built this
// way round rather than as a text box plus a hidden input.
//
// The two are kept in step through the DOM and nothing else: the calendar is driven by writing
// `data-value` on it (see Calendar.astro — it observes that attribute), and it reports back with a
// plain bubbling `change`. No runtime API, no imported controller, no shared state object.
//
// The popover is PopoverTrigger + PopoverContent rather than a private panel of its own, so every
// theme rule already written for Popover applies here — placement, flipping, light dismiss and
// Escape all come from `_anchor` and the platform. The script below imports `_anchor` directly
// (a side-effect ES singleton) because the `<Popover>` wrapper element is what usually pulls it in,
// and this primitive has its own root.
import type { HTMLAttributes } from "astro/types";

import Calendar from "../../forms/calendar";
import { PopoverContent, PopoverTrigger } from "../../overlays/popover";

type Props = HTMLAttributes<"input"> & {
  /** The selected date, `YYYY-MM-DD` — the same format `<input type="date">` speaks. */
  value?: string;
  min?: string;
  max?: string;
  size?: "sm" | "md" | "lg";
  state?: "default" | "error" | "success";
  locale?: string;
  weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
  side?: "bottom" | "top" | "left" | "right";
  align?: "start" | "center" | "end";
  /** Accessible name for the button that opens the calendar. */
  triggerLabel?: string;
};

const {
  value,
  min,
  max,
  size = "md",
  state = "default",
  locale,
  weekStart = 1,
  side = "bottom",
  align = "start",
  triggerLabel = "Choose date",
  class: className,
  ...rest
} = Astro.props;

const panelId = `date-picker-${crypto.randomUUID().slice(0, 8)}`;
---

<div data-slot="date-picker">
  <input
    type="date"
    value={value}
    min={min}
    max={max}
    data-slot="date-picker-input"
    data-size={size}
    data-state={state}
    class={className}
    {...rest}
  />
  <PopoverTrigger for={panelId} aria-label={triggerLabel} size={size}>
    <slot name="icon"
      ><svg
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
        aria-hidden="true"
      >
        <path d="M8 2v4M16 2v4M3 10h18"></path>
        <rect x="3" y="4" width="18" height="18" rx="2"></rect>
      </svg></slot
    >
  </PopoverTrigger>
  <PopoverContent id={panelId} side={side} align={align} aria-label={triggerLabel}>
    <Calendar value={value} min={min} max={max} locale={locale} weekStart={weekStart} />
  </PopoverContent>
</div>

<script>
  import "../../_anchor";

  import { onReadyOnce } from "../../_once";

  function wire(root: HTMLElement) {
    const input = root.querySelector<HTMLInputElement>('[data-slot="date-picker-input"]');
    const calendar = root.querySelector<HTMLElement>('[data-slot="calendar"]');
    const panel = root.querySelector<HTMLElement>('[data-slot="popover-content"]');
    if (!input || !calendar) return;

    // Field → calendar. Typing a date (or the OS picker) moves the grid to that month, so opening
    // the popover never shows a month unrelated to what the field says.
    const toCalendar = () => {
      calendar.dataset.value = input.value;
      if (input.value) calendar.dataset.month = input.value;
    };
    input.addEventListener("change", toCalendar);

    // Calendar → field. `change` on the input is what form libraries listen for, and dispatching it
    // ourselves is the price of writing `.value` from script — the platform only fires it for a user.
    calendar.addEventListener("change", () => {
      const value = calendar.dataset.value ?? "";
      if (value === input.value) return;
      input.value = value;
      input.dispatchEvent(new Event("change", { bubbles: true }));
      // Close on choose: a date picker whose panel stays open after a pick hides the field it just
      // filled. Focus goes back to the field rather than the trigger, because that is where the
      // value now is and where a keyboard user expects to continue.
      panel?.hidePopover();
      input.focus();
    });

    toCalendar();
  }

  onReadyOnce('[data-slot="date-picker"]', wire);
</script>
src/components/ui/forms/date-picker/DateRangePicker.astro
---
// src/components/ui/forms/date-picker/DateRangePicker.astro — DatePicker compound part (see ../../README.md).
// Two real <input type="date">s — a start and an end — sharing one Calendar. Same bargain as
// DatePicker: the inputs are the value, they submit and validate on their own, and the calendar is
// an enhancement over them.
//
// WHY THE RANGE LOGIC IS HERE AND NOT IN CALENDAR. Calendar holds one date, deliberately. Teaching
// it about two would put a second, optional mode in the hardest keyboard code in the library, for a
// feature only this file needs. Instead this script DECORATES the cells Calendar renders —
// `data-in-range` on everything between the ends, `data-range-start` / `data-range-end` on them —
// which is the same "state is a data attribute" contract everything else here follows, and leaves a
// theme one obvious hook. Paging the calendar re-fills those cells, so a MutationObserver re-runs
// the decoration after each render.
//
// Picking alternates: the first click sets the start, the next the end, and a pick before the start
// becomes the new start rather than an invalid backwards range. Focusing either field aims the next
// pick at that field, so a wrong end can be corrected without starting over.
import type { HTMLAttributes } from "astro/types";

import Calendar from "../../forms/calendar";
import { PopoverContent, PopoverTrigger } from "../../overlays/popover";

type Props = HTMLAttributes<"div"> & {
  start?: string;
  end?: string;
  min?: string;
  max?: string;
  /** `name` for the two fields: `${name}-start` and `${name}-end`. */
  name?: string;
  startLabel?: string;
  endLabel?: string;
  size?: "sm" | "md" | "lg";
  state?: "default" | "error" | "success";
  locale?: string;
  weekStart?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
  side?: "bottom" | "top" | "left" | "right";
  align?: "start" | "center" | "end";
  triggerLabel?: string;
};

const {
  start,
  end,
  min,
  max,
  name,
  startLabel = "Start date",
  endLabel = "End date",
  size = "md",
  state = "default",
  locale,
  weekStart = 1,
  side = "bottom",
  align = "start",
  triggerLabel = "Choose dates",
  class: className,
  ...rest
} = Astro.props;

const panelId = `date-range-${crypto.randomUUID().slice(0, 8)}`;
---

<div class={className} data-slot="date-range-picker" {...rest}>
  <input
    type="date"
    value={start}
    min={min}
    max={max}
    name={name && `${name}-start`}
    aria-label={startLabel}
    data-slot="date-range-picker-start"
    data-size={size}
    data-state={state}
  />
  <span data-slot="date-range-picker-separator" aria-hidden="true">–</span>
  <input
    type="date"
    value={end}
    min={start ?? min}
    max={max}
    name={name && `${name}-end`}
    aria-label={endLabel}
    data-slot="date-range-picker-end"
    data-size={size}
    data-state={state}
  />
  <PopoverTrigger for={panelId} aria-label={triggerLabel} size={size}>
    <slot name="icon"
      ><svg
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
        aria-hidden="true"
      >
        <path d="M8 2v4M16 2v4M3 10h18"></path>
        <rect x="3" y="4" width="18" height="18" rx="2"></rect>
      </svg></slot
    >
  </PopoverTrigger>
  <PopoverContent id={panelId} side={side} align={align} aria-label={triggerLabel}>
    <Calendar value={start} min={min} max={max} locale={locale} weekStart={weekStart} />
  </PopoverContent>
</div>

<script>
  import "../../_anchor";

  import { onReadyOnce } from "../../_once";
  import { parseISO } from "../calendar/calendar-grid";

  function wire(root: HTMLElement) {
    const startField = root.querySelector<HTMLInputElement>(
      '[data-slot="date-range-picker-start"]',
    );
    const endField = root.querySelector<HTMLInputElement>('[data-slot="date-range-picker-end"]');
    const calendar = root.querySelector<HTMLElement>('[data-slot="calendar"]');
    const grid = root.querySelector<HTMLElement>('[data-slot="calendar-grid"]');
    if (!startField || !endField || !calendar || !grid) return;
    const cells = [...grid.querySelectorAll<HTMLElement>('[data-slot="calendar-day"]')];

    /** Which field the next pick fills. Focusing a field aims at it; picking moves it along. */
    let filling: "start" | "end" = startField.value && !endField.value ? "end" : "start";
    startField.addEventListener("focus", () => (filling = "start"));
    endField.addEventListener("focus", () => (filling = "end"));

    const flag = (el: HTMLElement, key: "inRange" | "rangeStart" | "rangeEnd", on: boolean) => {
      if (on) el.dataset[key] = "true";
      else delete el.dataset[key];
    };

    /** Mark the cells Calendar just rendered. Cheap, and the only way a range can be seen at all. */
    const decorate = () => {
      const from = parseISO(startField.value);
      const to = parseISO(endField.value);
      for (const cell of cells) {
        const day = parseISO(cell.dataset.date ?? "");
        flag(cell, "rangeStart", day === from);
        flag(cell, "rangeEnd", day === to);
        flag(cell, "inRange", !Number.isNaN(from) && !Number.isNaN(to) && day > from && day < to);
      }
    };

    const commit = (field: HTMLInputElement, value: string) => {
      field.value = value;
      field.dispatchEvent(new Event("change", { bubbles: true }));
    };

    calendar.addEventListener("change", () => {
      const picked = calendar.dataset.value ?? "";
      if (!picked) return;
      const from = parseISO(startField.value);
      // A pick before the start is a new start, not a backwards range — the only reading that does
      // not silently produce an end the form would reject.
      if (filling === "start" || Number.isNaN(from) || parseISO(picked) < from) {
        commit(startField, picked);
        if (parseISO(endField.value) < parseISO(picked)) commit(endField, "");
        endField.min = picked; // the field itself now refuses an end before the start
        filling = "end";
      } else {
        commit(endField, picked);
        filling = "start";
      }
      decorate();
    });

    for (const field of [startField, endField]) {
      field.addEventListener("change", () => {
        if (field === startField) {
          endField.min = field.value || "";
          calendar.dataset.value = field.value;
          if (field.value) calendar.dataset.month = field.value;
        }
        decorate();
      });
    }

    // Calendar re-fills its 42 cells when you page, wiping the decoration with it. Watching the
    // cells' own `data-date` is the narrowest signal that a render happened; the microtask guard
    // collapses all 42 attribute changes of one render into a single pass.
    let queued = false;
    new MutationObserver(() => {
      if (queued) return;
      queued = true;
      queueMicrotask(() => {
        queued = false;
        decorate();
      });
    }).observe(grid, { subtree: true, attributeFilter: ["data-date"] });

    decorate();
  }

  onReadyOnce('[data-slot="date-range-picker"]', wire);
</script>
src/components/ui/forms/date-picker/index.ts
import DatePicker from "./DatePicker.astro";
import DateRangePicker from "./DateRangePicker.astro";

export { DatePicker, DateRangePicker };
export default DatePicker;

What you get

The component source, copied into your project by npx astrocraft-ui add forms/date-picker — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.