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