feat: DateTimePicker and DateRangePicker primitives
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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) }),
|
||||
)
|
||||
})
|
||||
})
|
||||
117
src/design-system/primitives/DateRangePicker/DateRangePicker.tsx
Normal file
117
src/design-system/primitives/DateRangePicker/DateRangePicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user