diff --git a/src/design-system/primitives/DateRangePicker/DateRangePicker.module.css b/src/design-system/primitives/DateRangePicker/DateRangePicker.module.css new file mode 100644 index 0000000..8597f75 --- /dev/null +++ b/src/design-system/primitives/DateRangePicker/DateRangePicker.module.css @@ -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; +} diff --git a/src/design-system/primitives/DateRangePicker/DateRangePicker.test.tsx b/src/design-system/primitives/DateRangePicker/DateRangePicker.test.tsx new file mode 100644 index 0000000..f433261 --- /dev/null +++ b/src/design-system/primitives/DateRangePicker/DateRangePicker.test.tsx @@ -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( + {}} + />, + ) + const inputs = container.querySelectorAll('input[type="datetime-local"]') + expect(inputs.length).toBe(2) + }) + + it('renders preset buttons', () => { + render( + {}} + />, + ) + 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( + , + ) + await user.click(screen.getByText('Last 1h')) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ start: expect.any(Date), end: expect.any(Date) }), + ) + }) +}) diff --git a/src/design-system/primitives/DateRangePicker/DateRangePicker.tsx b/src/design-system/primitives/DateRangePicker/DateRangePicker.tsx new file mode 100644 index 0000000..109178f --- /dev/null +++ b/src/design-system/primitives/DateRangePicker/DateRangePicker.tsx @@ -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(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 ( +
+
+ {presets.map((preset) => ( + handlePreset(preset)} + /> + ))} +
+
+ + + +
+
+ ) +} diff --git a/src/design-system/primitives/DateTimePicker/DateTimePicker.module.css b/src/design-system/primitives/DateTimePicker/DateTimePicker.module.css new file mode 100644 index 0000000..338df51 --- /dev/null +++ b/src/design-system/primitives/DateTimePicker/DateTimePicker.module.css @@ -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; +} diff --git a/src/design-system/primitives/DateTimePicker/DateTimePicker.tsx b/src/design-system/primitives/DateTimePicker/DateTimePicker.tsx new file mode 100644 index 0000000..afbfc38 --- /dev/null +++ b/src/design-system/primitives/DateTimePicker/DateTimePicker.tsx @@ -0,0 +1,51 @@ +import styles from './DateTimePicker.module.css' +import { forwardRef, type InputHTMLAttributes } from 'react' + +interface DateTimePickerProps extends Omit, '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( + ({ value, onChange, label, className, ...rest }, ref) => { + const inputValue = value ? toLocalDateTimeString(value) : '' + + function handleChange(e: React.ChangeEvent) { + if (!onChange) return + const v = e.target.value + onChange(v ? new Date(v) : null) + } + + return ( +
+ {label && } + +
+ ) + }, +) + +DateTimePicker.displayName = 'DateTimePicker'