feat: DateTimePicker and DateRangePicker primitives

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 09:34:28 +01:00
parent 52cf5129f7
commit 1192e1f780
5 changed files with 277 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
.root {
display: flex;
flex-direction: column;
gap: 10px;
}
.presets {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.inputs {
display: flex;
align-items: flex-end;
gap: 8px;
}
.inputs > * {
flex: 1;
}
.separator {
flex: 0;
font-size: 14px;
color: var(--text-faint);
padding-bottom: 8px;
white-space: nowrap;
}

View File

@@ -0,0 +1,43 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DateRangePicker } from './DateRangePicker'
describe('DateRangePicker', () => {
it('renders two datetime inputs', () => {
const { container } = render(
<DateRangePicker
value={{ start: new Date(), end: new Date() }}
onChange={() => {}}
/>,
)
const inputs = container.querySelectorAll('input[type="datetime-local"]')
expect(inputs.length).toBe(2)
})
it('renders preset buttons', () => {
render(
<DateRangePicker
value={{ start: new Date(), end: new Date() }}
onChange={() => {}}
/>,
)
expect(screen.getByText('Last 1h')).toBeInTheDocument()
expect(screen.getByText('Today')).toBeInTheDocument()
})
it('calls onChange when a preset is clicked', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(
<DateRangePicker
value={{ start: new Date(), end: new Date() }}
onChange={onChange}
/>,
)
await user.click(screen.getByText('Last 1h'))
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ start: expect.any(Date), end: expect.any(Date) }),
)
})
})

View File

@@ -0,0 +1,117 @@
import { useState } from 'react'
import styles from './DateRangePicker.module.css'
import { DateTimePicker } from '../DateTimePicker/DateTimePicker'
import { FilterPill } from '../FilterPill/FilterPill'
interface DateRange {
start: Date
end: Date
}
interface Preset {
label: string
value: string
}
const DEFAULT_PRESETS: Preset[] = [
{ label: 'Last 1h', value: 'last-1h' },
{ label: 'Last 6h', value: 'last-6h' },
{ label: 'Today', value: 'today' },
{ label: 'This shift', value: 'shift' },
{ label: 'Last 24h', value: 'last-24h' },
{ label: 'Last 7d', value: 'last-7d' },
{ label: 'Custom', value: 'custom' },
]
function computePresetRange(preset: string): DateRange {
const now = new Date()
const end = now
switch (preset) {
case 'last-1h':
return { start: new Date(now.getTime() - 60 * 60 * 1000), end }
case 'last-6h':
return { start: new Date(now.getTime() - 6 * 60 * 60 * 1000), end }
case 'today': {
const start = new Date(now)
start.setHours(0, 0, 0, 0)
return { start, end }
}
case 'shift': {
// "This shift" = last 8 hours
return { start: new Date(now.getTime() - 8 * 60 * 60 * 1000), end }
}
case 'last-24h':
return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end }
case 'last-7d':
return { start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), end }
default:
return { start: new Date(now.getTime() - 60 * 60 * 1000), end }
}
}
interface DateRangePickerProps {
value: DateRange
onChange: (range: DateRange) => void
presets?: Preset[]
className?: string
}
export function DateRangePicker({
value,
onChange,
presets = DEFAULT_PRESETS,
className,
}: DateRangePickerProps) {
const [activePreset, setActivePreset] = useState<string | null>(null)
function handlePreset(preset: Preset) {
if (preset.value === 'custom') {
setActivePreset('custom')
return
}
const range = computePresetRange(preset.value)
setActivePreset(preset.value)
onChange(range)
}
function handleStartChange(date: Date | null) {
if (!date) return
setActivePreset(null)
onChange({ ...value, start: date })
}
function handleEndChange(date: Date | null) {
if (!date) return
setActivePreset(null)
onChange({ ...value, end: date })
}
return (
<div className={`${styles.root} ${className ?? ''}`}>
<div className={styles.presets}>
{presets.map((preset) => (
<FilterPill
key={preset.value}
label={preset.label}
active={activePreset === preset.value}
onClick={() => handlePreset(preset)}
/>
))}
</div>
<div className={styles.inputs}>
<DateTimePicker
label="From"
value={value.start}
onChange={handleStartChange}
/>
<span className={styles.separator}></span>
<DateTimePicker
label="To"
value={value.end}
onChange={handleEndChange}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
.wrapper {
display: flex;
flex-direction: column;
gap: 4px;
}
.label {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.input {
width: 100%;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 12px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
cursor: pointer;
}
.input:focus {
border-color: var(--amber);
box-shadow: 0 0 0 3px var(--amber-bg);
}
.input::-webkit-calendar-picker-indicator {
opacity: 0.5;
cursor: pointer;
}

View File

@@ -0,0 +1,51 @@
import styles from './DateTimePicker.module.css'
import { forwardRef, type InputHTMLAttributes } from 'react'
interface DateTimePickerProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'value' | 'onChange'> {
value?: Date
onChange?: (date: Date | null) => void
label?: string
}
function toLocalDateTimeString(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0')
return (
date.getFullYear() +
'-' +
pad(date.getMonth() + 1) +
'-' +
pad(date.getDate()) +
'T' +
pad(date.getHours()) +
':' +
pad(date.getMinutes())
)
}
export const DateTimePicker = forwardRef<HTMLInputElement, DateTimePickerProps>(
({ value, onChange, label, className, ...rest }, ref) => {
const inputValue = value ? toLocalDateTimeString(value) : ''
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
if (!onChange) return
const v = e.target.value
onChange(v ? new Date(v) : null)
}
return (
<div className={`${styles.wrapper} ${className ?? ''}`}>
{label && <label className={styles.label}>{label}</label>}
<input
ref={ref}
type="datetime-local"
className={styles.input}
value={inputValue}
onChange={handleChange}
{...rest}
/>
</div>
)
},
)
DateTimePicker.displayName = 'DateTimePicker'