# P calendar 25

> Calendar with 24-hour autocomplete time input

A shadcn/ui block from the [@coss](https://coss.com/ui) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/coss/p-calendar-25).

- Page, with a live preview: https://shadcn.exchange/r/coss/p-calendar-25
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/coss/p-calendar-25-light-0d2accbe771dee10.png
- Registry item: https://coss.com/ui/r/p-calendar-25.json
- Categories: calendar, autocomplete, field, time
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://coss.com/ui/r/p-calendar-25.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `date-fns`, `lucide-react`
- Registry items: `@coss/calendar`, `@coss/autocomplete`, `@coss/field`

## Files

### `registry/default/particles/p-calendar-25.tsx`

```tsx
"use client";

import { format } from "date-fns";
import { ClockIcon } from "lucide-react";
import { useState } from "react";
import {
  Autocomplete,
  AutocompleteInput,
  AutocompleteItem,
  AutocompleteList,
  AutocompletePopup,
} from "@/registry/default/ui/autocomplete";
import { Calendar } from "@/registry/default/ui/calendar";
import { Field, FieldLabel } from "@/registry/default/ui/field";

const times = Array.from({ length: 96 }, (_, i) => {
  const hours = String(Math.floor(i / 4)).padStart(2, "0");
  const minutes = String((i % 4) * 15).padStart(2, "0");
  return `${hours}:${minutes}`;
});

function parseTime(value: string): string | null {
  const trimmed = value.trim();
  if (!trimmed) {
    return null;
  }

  const colonMatch = /^(\d{1,2}):(\d{1,2})$/.exec(trimmed);
  if (colonMatch) {
    const hours = Number(colonMatch[1]);
    const minutes = Number(colonMatch[2]);
    if (hours > 23 || minutes > 59) {
      return null;
    }
    return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
  }

  const digits = trimmed.replace(/\D/g, "");
  if (digits.length === 0 || digits.length > 4) {
    return null;
  }

  let hours: number;
  let minutes: number;

  if (digits.length <= 2) {
    hours = Number(digits);
    minutes = 0;
  } else if (digits.length === 3) {
    // HMM → H:MM (215 → 02:15)
    hours = Number(digits[0]);
    minutes = Number(digits.slice(1));
  } else {
    // HHMM → HH:MM (2150 → 21:50)
    hours = Number(digits.slice(0, 2));
    minutes = Number(digits.slice(2));
  }

  if (hours > 23 || minutes > 59) {
    return null;
  }

  return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
}

/**
 * Live input formatting from a digit buffer:
 * - ≤2 digits: as-is
 * - 3 digits: H:MM (unpadded hour so a 4th digit can still become HHMM)
 * - 4 digits: HH:mm
 */
function formatTimeInput(value: string) {
  const digits = value.replace(/\D/g, "").slice(0, 4);

  if (digits.length <= 2) {
    return digits;
  }

  if (digits.length === 3) {
    const minutes = Number(digits.slice(1));
    if (minutes <= 59) {
      return `${digits[0]}:${digits.slice(1)}`;
    }
    return digits;
  }

  return parseTime(digits) ?? digits;
}

function filterTime(item: string, query: string) {
  const trimmed = query.trim().toLowerCase();
  if (!trimmed) {
    return true;
  }

  if (item.toLowerCase().startsWith(trimmed)) {
    return true;
  }

  const itemDigits = item.replace(/\D/g, "");
  const queryDigits = trimmed.replace(/\D/g, "");
  if (!queryDigits) {
    return false;
  }

  const itemHour = Number(itemDigits.slice(0, 2));
  const itemMinutes = itemDigits.slice(2);
  // Padded "0615" and unpadded "615" so "61" matches 06:15 while typing toward 615
  const itemDigitsUnpadded = `${itemHour}${itemMinutes}`;

  if (
    itemDigits.startsWith(queryDigits) ||
    itemDigitsUnpadded.startsWith(queryDigits)
  ) {
    return true;
  }

  if (queryDigits.length >= 3) {
    const normalized = parseTime(queryDigits);
    return normalized === item;
  }

  const queryHour = Number(queryDigits);

  if (trimmed.includes(":")) {
    const minuteQuery = trimmed.split(":")[1]?.replace(/\D/g, "") ?? "";
    return (
      itemHour === queryHour &&
      (!minuteQuery || itemMinutes.startsWith(minuteQuery))
    );
  }

  if (queryDigits.length === 1) {
    return itemHour === queryHour || String(itemHour).startsWith(queryDigits);
  }

  return queryHour <= 23 && itemHour === queryHour;
}

function applyTime(date: Date, time: string) {
  const parsed = parseTime(time);
  if (!parsed) {
    return date;
  }
  const hours = Number(parsed.slice(0, 2));
  const minutes = Number(parsed.slice(3, 5));
  const next = new Date(date);
  next.setHours(hours, minutes, 0, 0);
  return next;
}

export default function Particle() {
  const [date, setDate] = useState<Date | undefined>(() => {
    const initial = new Date();
    initial.setHours(12, 0, 0, 0);
    return initial;
  });
  const [month, setMonth] = useState<Date>(() => new Date());
  const [time, setTime] = useState("12:00");
  const [isEditing, setIsEditing] = useState(false);

  const handleDaySelect = (selectedDate: Date | undefined) => {
    if (!selectedDate) {
      setDate(undefined);
      return;
    }
    const next = applyTime(selectedDate, time);
    setDate(next);
    setMonth(next);
  };

  const handleTimeChange = (value: string) => {
    setIsEditing(true);
    const next = formatTimeInput(value);
    setTime(next);

    // List picks and completed HH:mm only — avoid committing mid-typing ("2:1" → 02:01)
    if (date && /^\d{2}:\d{2}$/.test(next)) {
      setDate(applyTime(date, next));
    }
  };

  const handleTimeBlur = () => {
    const normalized = parseTime(time);
    if (normalized) {
      setTime(normalized);
      if (date) {
        setDate(applyTime(date, normalized));
      }
    } else if (date) {
      setTime(format(date, "HH:mm"));
    }
    setIsEditing(false);
  };

  const filterQuery = isEditing ? time : "";
  const matchingTimes = times.filter((item) => filterTime(item, filterQuery));

  return (
    <div className="flex w-fit flex-col gap-2">
      <Calendar
        mode="single"
        month={month}
        onMonthChange={setMonth}
        onSelect={handleDaySelect}
        selected={date}
      />
      <Field className="w-0 min-w-full flex-row items-center gap-3">
        <FieldLabel className="whitespace-nowrap text-xs">
          Enter time
        </FieldLabel>
        <Autocomplete
          autoHighlight
          filter={(item, query) => filterTime(item, isEditing ? query : "")}
          items={times}
          onValueChange={handleTimeChange}
          openOnInputClick
          value={time}
        >
          <AutocompleteInput
            aria-label="Enter time"
            inputMode="numeric"
            maxLength={5}
            onBlur={handleTimeBlur}
            onFocus={(event) => {
              setIsEditing(false);
              event.currentTarget.select();
            }}
            placeholder="HH:mm"
            startAddon={<ClockIcon aria-hidden="true" />}
          />
          <AutocompletePopup
            className={matchingTimes.length === 0 ? "hidden" : undefined}
          >
            <AutocompleteList>
              {(item: string) => (
                <AutocompleteItem key={item} value={item}>
                  {item}
                </AutocompleteItem>
              )}
            </AutocompleteList>
          </AutocompletePopup>
        </Autocomplete>
      </Field>
    </div>
  );
}
```
