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, shown without its comments; the files you install keep them, with the reasoning behind every rule.

src/components/ui/forms/date-picker/DatePicker.astro
---
import type { HTMLAttributes } from "astro/types";

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

type Props = HTMLAttributes<"input"> & {
  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";
  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;

    const toCalendar = () => {
      calendar.dataset.value = input.value;
      if (input.value) calendar.dataset.month = input.value;
    };
    input.addEventListener("change", toCalendar);

    calendar.addEventListener("change", () => {
      const value = calendar.dataset.value ?? "";
      if (value === input.value) return;
      input.value = value;
      input.dispatchEvent(new Event("change", { bubbles: true }));
      panel?.hidePopover();
      input.focus();
    });

    toCalendar();
  }

  onReadyOnce('[data-slot="date-picker"]', wire);
</script>
src/components/ui/forms/date-picker/DateRangePicker.astro
---
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?: 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"]')];

    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];
    };

    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);
      if (filling === "start" || Number.isNaN(from) || parseISO(picked) < from) {
        commit(startField, picked);
        if (parseISO(endField.value) < parseISO(picked)) commit(endField, "");
        endField.min = picked;
        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();
      });
    }

    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.