p1-time-ui
v0.11.0
Published
Lightweight, headless-ish React date + time + range pickers. Tailwind-friendly, MIT, no dependencies beyond React + clsx.
Maintainers
Readme
p1-time-ui
A small, MIT-licensed, Tailwind-friendly React date + time + range picker set. Six components covering single + range pickers across dates, times, and combined date-times — all with a unified controlled-component string API.
- Six components —
TimePicker,TimeRangePicker,DatePicker,DateRangePicker,DateTimePicker,DateTimeRangePicker. The range pickers use a single shared popover with two parallel columns / two calendars, so picking a start and an end takes two clicks without re-opening anything. - No design-system lock-in / no date library — just React + a tiny
clsx. The whole package is ~14 KB minified+gzipped (50 KB unminified). - Strings on the API surface —
'HH:mm'for times,'YYYY-MM-DD'for dates,'YYYY-MM-DDTHH:mm'for date-times. Date objects are scratch values only, so timezone and DST never bite you. - Typed end-to-end — strict TypeScript declarations ship in
dist/index.d.ts. - Themable — every visible colour reads from a
--p1tu-*CSS variable with a sensible default, so consumers can match their design system without forking the component. - Keyboard-first — typing into the input is the primary path; the popover is the discovery aid.
Install
yarn add p1-time-ui
# or
npm install p1-time-uiPeer deps: react >= 18, react-dom >= 18.
Styling (Tailwind required)
p1-time-ui ships no CSS. Every component renders Tailwind CSS utility classes directly in its markup, so your app must have Tailwind configured and Tailwind's content scanner must see this package's compiled files — otherwise the utilities the components depend on are tree-shaken out of your build and the pickers render unstyled.
Add the package's dist to your content globs (Tailwind v3):
// tailwind.config.js
export default {
content: [
'./src/**/*.{js,ts,jsx,tsx}',
// Let Tailwind discover the classes baked into p1-time-ui:
'./node_modules/p1-time-ui/dist/**/*.{js,mjs}',
],
// ...
}On Tailwind v4 (CSS-first config) add an @source directive instead:
@import 'tailwindcss';
@source '../node_modules/p1-time-ui/dist';The components carry their own colours via --p1tu-* CSS variables with
sensible defaults (see Theming), so once Tailwind is scanning
them they look right with zero extra CSS — overriding a variable is
optional.
The pickers
TimePicker (single time)
import { useState } from 'react'
import { TimePicker } from 'p1-time-ui'
const [time, setTime] = useState('09:00')
<TimePicker
label="Start time"
value={time}
onChange={setTime}
step={15} // 5 / 10 / 15 / 30 / 60 — must divide 60
min="07:00"
max="22:00"
/>The popover is a multi-section digital clock (MUI X style): an
Hours column and a Minutes column side by side, rather than one
long scroll of every HH:mm. Pick the hour (the cursor hands off to
the minutes column), then the minute (which completes the value and
closes). The selected value in each column is a filled accent pill.
Keyboard: ↑/↓ move within the active column, ←/→ switch columns, Enter
commits, Esc closes. Free-typing HH:mm in the field still works as
the primary fast path. The step prop drives the Minutes column
(e.g. 15 → 00 / 15 / 30 / 45); buildHours / buildMinutesForHour
are exported if you need the same lists outside the component.
TimeRangePicker (start + end times, one popover)
import { useState } from 'react'
import { TimeRangePicker } from 'p1-time-ui'
const [range, setRange] = useState({ start: '09:00', end: '10:00' })
<TimeRangePicker
value={range}
onChange={setRange}
step={15}
min="07:00"
max="22:00"
defaultDurationMinutes={60} // auto-fills end to start+60min
enforceEndAfterStart // dim end slots that are ≤ start
/>The popover renders two parallel scrollable columns (Start | End). defaultDurationMinutes auto-fills the end whenever the user picks a new start — until they manually touch the end field, then the auto-fill goes silent. Clearing the end re-arms it.
DateRangePicker/RangeCalendarrender the selected span as one continuous band (square cells,gap-0) with the start and end as filled circles — hover previews the band to the day under the cursor.
Single-input mode — pass singleInput to collapse the two fields into one combobox showing "09:00 → 10:00". The popover stacks a Start / End segmented tab over a single multi-section Hours|Minutes clock; picking the start time auto-advances the tab to End, and picking the end completes + closes. defaultDurationMinutes, enforceEndAfterStart, and the label caption all apply.
<TimeRangePicker
singleInput
label="Time"
value={range}
onChange={setRange}
step={15}
defaultDurationMinutes={60}
/>DatePicker (single date)
import { useState } from 'react'
import { DatePicker } from 'p1-time-ui'
const [date, setDate] = useState('2026-05-26')
<DatePicker
label="Visit"
value={date}
onChange={setDate}
min="2026-01-01"
max="2026-12-31"
firstDayOfWeek={1} // 0=Sun, 1=Mon (default 1)
locale="en-GB"
displayFormat="DD-MM-YYYY" // input shows 26-05-2026 (default YYYY-MM-DD)
isDateDisabled={(d) => d.getDay() === 0} // disable Sundays
/>Six-row month calendar with prev/next chevrons, weekday header, localised month title. Picked day = solid pill, today = ring, dates outside the visible month or outside [min, max] are dimmed.
The value / onChange contract is always canonical ISO YYYY-MM-DD — that doesn't change. displayFormat only controls what the text input renders and accepts when typed: 'YYYY-MM-DD' (default), 'DD-MM-YYYY', 'DD/MM/YYYY', or 'MM/DD/YYYY'. The same conversion is available standalone via the exported formatDateForDisplay(iso, format) / parseDateFromDisplay(text, format) helpers.
DateRangePicker (start + end dates, two-month popover)
import { useState } from 'react'
import { DateRangePicker } from 'p1-time-ui'
const [range, setRange] = useState({ start: '2026-05-26', end: '2026-06-02' })
<DateRangePicker
value={range}
onChange={setRange}
monthsToShow={2} // 1 or 2 (default 2)
min="2026-01-01"
max="2027-12-31"
enforceEndAfterStart // clear end if a new start is ≥ it
/>Two side-by-side calendars (consecutive months). Click a start day → hover over a candidate end day → preview the range with a soft fill → click to commit. Clicking before the current start treats it as a fresh start. Date inputs accept typed YYYY-MM-DD on either side.
DateTimePicker (single date + time)
import { useState } from 'react'
import { DateTimePicker } from 'p1-time-ui'
const [dt, setDt] = useState('2026-05-26T09:00')
<DateTimePicker
value={dt} // 'YYYY-MM-DDTHH:mm' local
onChange={setDt}
step={15}
minDate="2026-01-01"
maxDate="2026-12-31"
minTime="07:00"
maxTime="22:00"
/>Composes DatePicker and TimePicker side by side. The combined value is an ISO-local string 'YYYY-MM-DDTHH:mm' (no timezone designator — the value represents a local wall-clock moment). Half-filled values stay coherent: clearing one side reduces the output to just the other side (or empty), never a malformed 'T09:00'.
DateTimeRangePicker (start + end date+time)
import { useState } from 'react'
import { DateTimeRangePicker } from 'p1-time-ui'
const [range, setRange] = useState({
start: '2026-05-26T09:00',
end: '2026-05-26T10:00',
})
<DateTimeRangePicker
value={range}
onChange={setRange}
step={15}
minDate="2026-01-01"
maxDate="2027-12-31"
defaultDurationMinutes={60}
/>Stacks a DateRangePicker (dates) on top of a TimeRangePicker (times). The two sub-pickers work independently — clicking into the dates row opens the calendar, into the times row opens the slot columns — so users can edit either dimension without committing the other. defaultDurationMinutes auto-fills the end time when the start time changes.
Theming
Override any of these CSS variables on the picker root (or anywhere above it in the cascade) to retheme:
| Variable | Default | What it sets |
| --- | --- | --- |
| --p1tu-bg | #fafafa | Input field background |
| --p1tu-bg-disabled | #ffffff | Disabled input background |
| --p1tu-text | #111827 | Typed value colour |
| --p1tu-label | #6b7280 | Floating label colour |
| --p1tu-placeholder | #9ca3af | Placeholder colour |
| --p1tu-border | #e5e7eb | Idle border |
| --p1tu-border-error | #dc2626 | Error border |
| --p1tu-border-disabled | #f5f5f5 | Disabled border |
| --p1tu-icon | #9ca3af | Chevron / arrow colour |
| --p1tu-icon-hover | #6b7280 | Chevron hover colour |
| --p1tu-arrow | #9ca3af | → separator between start/end fields |
| --p1tu-divider | #e5e7eb | Column divider inside the range popover |
| --p1tu-column-title | #6b7280 | Column / weekday headers |
| --p1tu-slot-text | #111827 | Slot / day text |
| --p1tu-slot-selected-bg | #1f2937 (date) / #eef2ff (time) | Selected pill background |
| --p1tu-slot-selected-text | #ffffff (date) / #1e293b (time) | Selected pill text |
| --p1tu-slot-hover-bg | #f9fafb | Slot hover background |
| --p1tu-slot-dim | #cbd5e1 | Dimmed (out-of-range / out-of-month) text |
| --p1tu-range-bg | #eef2ff | In-range day-cell fill on DateRangePicker |
| --p1tu-today-ring | #cbd5e1 | Today-marker ring on the calendar |
| --p1tu-focus-ring | #1f2937 | Keyboard-focus outline on calendar day cells + active range-tab underline |
Utility exports
Same time + date helpers the pickers use, exported so consumers can do their own slot / day math (e.g. for server-side validation parity):
import {
// Time
parseTime, formatTime, buildSlots, addMinutes, compareTime,
isWithinBounds, pad2, MINUTES_PER_DAY,
// Date
parseDate, formatDate, formatDateLocale,
addDays, addMonths, compareDate,
isSameDay, isSameMonth, isWithinDateBounds,
monthGrid, monthTitle, weekdayShortLabels,
} from 'p1-time-ui'
import type { Weekday } from 'p1-time-ui'Development
yarn install
yarn build # tsup → dist/{index.js,index.mjs,index.d.ts}
yarn test # vitest run
yarn typecheck # tsc --noEmit
yarn lint # eslint srcCurrent test count: 160 (80 utility unit tests + 80 component interaction tests).
License
MIT © Mathematica AI
