feat: add SegmentedTabs, custom DateTimePicker, redesign time range selector
All checks were successful
Build & Publish / publish (push) Successful in 44s
All checks were successful
Build & Publish / publish (push) Successful in 44s
New components: - SegmentedTabs — pill-style tabs with sliding animated indicator, trailing slot for custom content, MutationObserver for dynamic resizing - Custom DateTimePicker — replaces native datetime-local with calendar grid, hour/minute inputs, Now/Apply buttons, portal dropdown Time range selector redesign: - Uses SegmentedTabs with inline from/to DateTimePicker triggers - "now" shown as clickable placeholder when to-date is not explicitly set - Preset selection keeps to-date as "now" until user sets it - No more "Custom" button — last tab is the live date range Other improvements: - FilterPill gains activeColor prop for status-colored active states - TopBar and EventFeed status pills now use colored dots + activeColor - Inventory nav expanded to full component-level table of contents - COMPONENT_GUIDE.md updated with new components - DateRangePicker test updated for custom DateTimePicker Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
- Page-level attention banner → **Alert**
|
- Page-level attention banner → **Alert**
|
||||||
- Temporary non-blocking feedback → **Toast** (via `useToast`)
|
- Temporary non-blocking feedback → **Toast** (via `useToast`)
|
||||||
- Destructive action confirmation → **AlertDialog**
|
- Destructive action confirmation → **AlertDialog**
|
||||||
|
- Destructive action needing typed confirmation → **ConfirmDialog**
|
||||||
- Generic dialog with custom content → **Modal**
|
- Generic dialog with custom content → **Modal**
|
||||||
|
|
||||||
### "I need a form input"
|
### "I need a form input"
|
||||||
@@ -19,6 +20,8 @@
|
|||||||
- Yes/no with label → **Checkbox**
|
- Yes/no with label → **Checkbox**
|
||||||
- One of N options (≤5) → **RadioGroup** + **RadioItem**
|
- One of N options (≤5) → **RadioGroup** + **RadioItem**
|
||||||
- One of N options (>5) → **Select**
|
- One of N options (>5) → **Select**
|
||||||
|
- Select multiple from a list → **MultiSelect**
|
||||||
|
- Edit text inline without a form → **InlineEdit**
|
||||||
- Date/time → **DateTimePicker**
|
- Date/time → **DateTimePicker**
|
||||||
- Date range → **DateRangePicker**
|
- Date range → **DateRangePicker**
|
||||||
- Wrap any input with label/error/hint → **FormField**
|
- Wrap any input with label/error/hint → **FormField**
|
||||||
@@ -58,6 +61,7 @@
|
|||||||
- Collapsible sections (standalone) → **Collapsible**
|
- Collapsible sections (standalone) → **Collapsible**
|
||||||
- Multiple collapsible sections (one/many open) → **Accordion**
|
- Multiple collapsible sections (one/many open) → **Accordion**
|
||||||
- Tabbed content → **Tabs**
|
- Tabbed content → **Tabs**
|
||||||
|
- Tab switching with pill/segment style → **SegmentedTabs**
|
||||||
- Side panel inspector → **DetailPanel**
|
- Side panel inspector → **DetailPanel**
|
||||||
- Section with title + action → **SectionHeader**
|
- Section with title + action → **SectionHeader**
|
||||||
- Empty content placeholder → **EmptyState**
|
- Empty content placeholder → **EmptyState**
|
||||||
@@ -79,6 +83,7 @@
|
|||||||
### "I need filtering"
|
### "I need filtering"
|
||||||
- Filter pill/chip → **FilterPill**
|
- Filter pill/chip → **FilterPill**
|
||||||
- Full filter bar with search → **FilterBar**
|
- Full filter bar with search → **FilterBar**
|
||||||
|
- Select multiple from a list → **MultiSelect**
|
||||||
|
|
||||||
## Composition Patterns
|
## Composition Patterns
|
||||||
|
|
||||||
@@ -157,6 +162,7 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
|
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
|
||||||
| Collapsible | primitive | Single expand/collapse section |
|
| Collapsible | primitive | Single expand/collapse section |
|
||||||
| CommandPalette | composite | Full-screen search and command interface |
|
| CommandPalette | composite | Full-screen search and command interface |
|
||||||
|
| ConfirmDialog | composite | Type-to-confirm destructive action dialog built on Modal. Props: open, onClose, onConfirm, title, message, confirmText, confirmLabel, cancelLabel, variant, loading, className |
|
||||||
| DataTable | composite | Sortable, paginated data table with row actions. Use `flush` prop when embedded inside a container that provides its own border/radius |
|
| DataTable | composite | Sortable, paginated data table with row actions. Use `flush` prop when embedded inside a container that provides its own border/radius |
|
||||||
| DateRangePicker | primitive | Date range selection with presets |
|
| DateRangePicker | primitive | Date range selection with presets |
|
||||||
| DateTimePicker | primitive | Single date/time input |
|
| DateTimePicker | primitive | Single date/time input |
|
||||||
@@ -169,12 +175,14 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| FilterPill | primitive | Individual filter chip (active/inactive), supports forwardRef |
|
| FilterPill | primitive | Individual filter chip (active/inactive), supports forwardRef |
|
||||||
| FormField | primitive | Wrapper adding label, hint, error to any input |
|
| FormField | primitive | Wrapper adding label, hint, error to any input |
|
||||||
| InfoCallout | primitive | Inline contextual note with variant colors |
|
| InfoCallout | primitive | Inline contextual note with variant colors |
|
||||||
|
| InlineEdit | primitive | Click-to-edit text field. Enter saves, Escape/blur cancels. Props: value, onSave, placeholder, disabled, className |
|
||||||
| Input | primitive | Single-line text input with optional icon |
|
| Input | primitive | Single-line text input with optional icon |
|
||||||
| KeyboardHint | primitive | Keyboard shortcut display |
|
| KeyboardHint | primitive | Keyboard shortcut display |
|
||||||
| Label | primitive | Form label with optional required asterisk |
|
| Label | primitive | Form label with optional required asterisk |
|
||||||
| LineChart | composite | Time series line visualization |
|
| LineChart | composite | Time series line visualization |
|
||||||
| MenuItem | composite | Sidebar navigation item with health/count |
|
| MenuItem | composite | Sidebar navigation item with health/count |
|
||||||
| Modal | composite | Generic dialog overlay with backdrop |
|
| Modal | composite | Generic dialog overlay with backdrop |
|
||||||
|
| MultiSelect | composite | Dropdown with searchable checkbox list and Apply action. Props: options, value, onChange, placeholder, searchable, disabled, className |
|
||||||
| MonoText | primitive | Inline monospace text (xs, sm, md) |
|
| MonoText | primitive | Inline monospace text (xs, sm, md) |
|
||||||
| Pagination | primitive | Page navigation controls |
|
| Pagination | primitive | Page navigation controls |
|
||||||
| Popover | composite | Click-triggered floating panel with arrow |
|
| Popover | composite | Click-triggered floating panel with arrow |
|
||||||
@@ -183,6 +191,7 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
| RadioGroup | primitive | Single-select option group (use with RadioItem) |
|
| RadioGroup | primitive | Single-select option group (use with RadioItem) |
|
||||||
| RadioItem | primitive | Individual radio option within RadioGroup |
|
| RadioItem | primitive | Individual radio option within RadioGroup |
|
||||||
| SectionHeader | primitive | Section title with optional action button |
|
| SectionHeader | primitive | Section title with optional action button |
|
||||||
|
| SegmentedTabs | composite | Pill-style segmented tab bar with sliding animated indicator. Same API as Tabs but with elevated active state. Props: tabs, active, onChange, trailing, trailingValue, className |
|
||||||
| Select | primitive | Dropdown select input |
|
| Select | primitive | Dropdown select input |
|
||||||
| ShortcutsBar | composite | Keyboard shortcuts reference bar |
|
| ShortcutsBar | composite | Keyboard shortcuts reference bar |
|
||||||
| Skeleton | primitive | Loading placeholder (text, circular, rectangular) |
|
| Skeleton | primitive | Loading placeholder (text, circular, rectangular) |
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
.bar {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-inset);
|
||||||
|
padding: 3px;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sliding indicator behind the active tab */
|
||||||
|
.indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: calc(var(--radius-md) - 2px);
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
transition: left 0.2s ease, width 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: calc(var(--radius-md) - 2px);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active .count {
|
||||||
|
background: var(--amber-bg);
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Trailing tab — a div, not a button, hosting custom content */
|
||||||
|
.trailingTab {
|
||||||
|
cursor: default;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
@@ -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 { SegmentedTabs } from './SegmentedTabs'
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ label: 'Users', value: 'users' },
|
||||||
|
{ label: 'Groups', value: 'groups', count: 4 },
|
||||||
|
{ label: 'Roles', value: 'roles' },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('SegmentedTabs', () => {
|
||||||
|
it('renders all tabs', () => {
|
||||||
|
render(<SegmentedTabs tabs={TABS} active="users" onChange={vi.fn()} />)
|
||||||
|
expect(screen.getByRole('tab', { name: /Users/ })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('tab', { name: /Groups/ })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('tab', { name: /Roles/ })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the active tab with aria-selected', () => {
|
||||||
|
render(<SegmentedTabs tabs={TABS} active="groups" onChange={vi.fn()} />)
|
||||||
|
expect(screen.getByRole('tab', { name: /Groups/ })).toHaveAttribute('aria-selected', 'true')
|
||||||
|
expect(screen.getByRole('tab', { name: /Users/ })).toHaveAttribute('aria-selected', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onChange when a tab is clicked', async () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<SegmentedTabs tabs={TABS} active="users" onChange={onChange} />)
|
||||||
|
await user.click(screen.getByRole('tab', { name: /Roles/ }))
|
||||||
|
expect(onChange).toHaveBeenCalledWith('roles')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders count badge when provided', () => {
|
||||||
|
render(<SegmentedTabs tabs={TABS} active="users" onChange={vi.fn()} />)
|
||||||
|
expect(screen.getByText('4')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has tablist role on container', () => {
|
||||||
|
render(<SegmentedTabs tabs={TABS} active="users" onChange={vi.fn()} />)
|
||||||
|
expect(screen.getByRole('tablist')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
101
src/design-system/composites/SegmentedTabs/SegmentedTabs.tsx
Normal file
101
src/design-system/composites/SegmentedTabs/SegmentedTabs.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useRef, useEffect, useState as useLocalState, useCallback, useMemo, type ReactNode } from 'react'
|
||||||
|
import styles from './SegmentedTabs.module.css'
|
||||||
|
|
||||||
|
interface TabItem {
|
||||||
|
label: ReactNode
|
||||||
|
count?: number
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SegmentedTabsProps {
|
||||||
|
tabs: TabItem[]
|
||||||
|
active: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
/** Extra element rendered as the last "tab" — participates in indicator animation.
|
||||||
|
* Use `trailingValue` to assign it a value for active state matching. */
|
||||||
|
trailing?: ReactNode
|
||||||
|
trailingValue?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SegmentedTabs({ tabs, active, onChange, trailing, trailingValue, className }: SegmentedTabsProps) {
|
||||||
|
const barRef = useRef<HTMLDivElement>(null)
|
||||||
|
const tabRefs = useRef<Map<string, HTMLElement>>(new Map())
|
||||||
|
const [indicator, setIndicator] = useLocalState<{ left: number; width: number } | null>(null)
|
||||||
|
|
||||||
|
// Recalculate when labels change (e.g. dynamic date range text)
|
||||||
|
const tabsKey = useMemo(() => tabs.map((t) => `${t.value}:${typeof t.label === 'string' ? t.label : ''}`).join('|'), [tabs])
|
||||||
|
|
||||||
|
const updateIndicator = useCallback(() => {
|
||||||
|
const bar = barRef.current
|
||||||
|
const activeEl = tabRefs.current.get(active)
|
||||||
|
if (!bar || !activeEl) return
|
||||||
|
const barRect = bar.getBoundingClientRect()
|
||||||
|
const elRect = activeEl.getBoundingClientRect()
|
||||||
|
setIndicator({
|
||||||
|
left: elRect.left - barRect.left,
|
||||||
|
width: elRect.width,
|
||||||
|
})
|
||||||
|
}, [active, tabsKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = requestAnimationFrame(updateIndicator)
|
||||||
|
return () => cancelAnimationFrame(id)
|
||||||
|
}, [updateIndicator])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener('resize', updateIndicator)
|
||||||
|
return () => window.removeEventListener('resize', updateIndicator)
|
||||||
|
}, [updateIndicator])
|
||||||
|
|
||||||
|
// Observe DOM mutations (e.g. trailing content text changes) to resize indicator
|
||||||
|
useEffect(() => {
|
||||||
|
const bar = barRef.current
|
||||||
|
if (!bar) return
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
requestAnimationFrame(updateIndicator)
|
||||||
|
})
|
||||||
|
observer.observe(bar, { childList: true, subtree: true, characterData: true })
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [updateIndicator])
|
||||||
|
|
||||||
|
const trailingActive = trailingValue !== undefined && active === trailingValue
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={barRef} className={`${styles.bar} ${className ?? ''}`} role="tablist">
|
||||||
|
{indicator && (
|
||||||
|
<span
|
||||||
|
className={styles.indicator}
|
||||||
|
style={{ left: indicator.left, width: indicator.width }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.value}
|
||||||
|
ref={(el) => { if (el) tabRefs.current.set(tab.value, el); else tabRefs.current.delete(tab.value) }}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab.value === active}
|
||||||
|
className={`${styles.tab} ${tab.value === active ? styles.active : ''}`}
|
||||||
|
onClick={() => onChange(tab.value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className={styles.label}>{tab.label}</span>
|
||||||
|
{tab.count !== undefined && (
|
||||||
|
<span className={styles.count}>{tab.count}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{trailing && trailingValue && (
|
||||||
|
<div
|
||||||
|
ref={(el) => { if (el) tabRefs.current.set(trailingValue, el); else tabRefs.current.delete(trailingValue) }}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={trailingActive}
|
||||||
|
className={`${styles.tab} ${styles.trailingTab} ${trailingActive ? styles.active : ''}`}
|
||||||
|
>
|
||||||
|
{trailing}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ export { Popover } from './Popover/Popover'
|
|||||||
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
|
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
|
||||||
export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline'
|
export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline'
|
||||||
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
|
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
|
||||||
|
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
|
||||||
export { Tabs } from './Tabs/Tabs'
|
export { Tabs } from './Tabs/Tabs'
|
||||||
export { ToastProvider, useToast } from './Toast/Toast'
|
export { ToastProvider, useToast } from './Toast/Toast'
|
||||||
export { TreeView } from './TreeView/TreeView'
|
export { TreeView } from './TreeView/TreeView'
|
||||||
|
|||||||
@@ -4,15 +4,17 @@ import userEvent from '@testing-library/user-event'
|
|||||||
import { DateRangePicker } from './DateRangePicker'
|
import { DateRangePicker } from './DateRangePicker'
|
||||||
|
|
||||||
describe('DateRangePicker', () => {
|
describe('DateRangePicker', () => {
|
||||||
it('renders two datetime inputs', () => {
|
it('renders two datetime picker triggers', () => {
|
||||||
const { container } = render(
|
render(
|
||||||
<DateRangePicker
|
<DateRangePicker
|
||||||
value={{ start: new Date(), end: new Date() }}
|
value={{ start: new Date('2026-03-19T10:00'), end: new Date('2026-03-19T11:00') }}
|
||||||
onChange={() => {}}
|
onChange={() => {}}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
const inputs = container.querySelectorAll('input[type="datetime-local"]')
|
// DateTimePicker renders button triggers with formatted date text
|
||||||
expect(inputs.length).toBe(2)
|
const buttons = screen.getAllByRole('button')
|
||||||
|
// At least 2 buttons are the from/to date picker triggers (plus preset pills)
|
||||||
|
expect(buttons.length).toBeGreaterThanOrEqual(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders preset buttons', () => {
|
it('renders preset buttons', () => {
|
||||||
|
|||||||
@@ -12,26 +12,217 @@
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.trigger {
|
||||||
width: 100%;
|
padding: 0 4px;
|
||||||
padding: 6px 10px;
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: color 0.15s;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger:hover {
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger:focus-visible {
|
||||||
|
outline: 1px solid var(--amber);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel */
|
||||||
|
.panel {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 600;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 12px;
|
||||||
|
width: 260px;
|
||||||
|
animation: panelIn 0.12s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes panelIn {
|
||||||
|
from { opacity: 0; transform: translateY(-4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Month navigation */
|
||||||
|
.monthNav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monthLabel {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navBtn {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background 0.1s, color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navBtn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar grid */
|
||||||
|
.calendar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dayHeader {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-faint);
|
||||||
|
text-align: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dayEmpty {
|
||||||
|
/* placeholder for offset days */
|
||||||
|
}
|
||||||
|
|
||||||
|
.day {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dayToday {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daySelected {
|
||||||
|
background: var(--amber);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daySelected:hover {
|
||||||
|
background: var(--amber-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Time selector */
|
||||||
|
.timeRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeLabel {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeInput {
|
||||||
|
width: 32px;
|
||||||
|
padding: 4px 6px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.15s, box-shadow 0.15s;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:focus {
|
.timeInput:focus {
|
||||||
border-color: var(--amber);
|
border-color: var(--amber);
|
||||||
box-shadow: 0 0 0 3px var(--amber-bg);
|
box-shadow: 0 0 0 2px var(--amber-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input::-webkit-calendar-picker-indicator {
|
.timeSep {
|
||||||
opacity: 0.5;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todayBtn {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--amber);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.todayBtn:hover {
|
||||||
|
background: var(--amber-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doneBtn {
|
||||||
|
padding: 4px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--amber);
|
||||||
|
color: #fff;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doneBtn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doneBtn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,204 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import styles from './DateTimePicker.module.css'
|
import styles from './DateTimePicker.module.css'
|
||||||
import { forwardRef, type InputHTMLAttributes } from 'react'
|
|
||||||
|
|
||||||
interface DateTimePickerProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'value' | 'onChange'> {
|
interface DateTimePickerProps {
|
||||||
value?: Date
|
value?: Date
|
||||||
onChange?: (date: Date | null) => void
|
onChange?: (date: Date | null) => void
|
||||||
label?: string
|
label?: string
|
||||||
|
placeholder?: string
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function toLocalDateTimeString(date: Date): string {
|
const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
|
||||||
|
function getDaysInMonth(year: number, month: number): number {
|
||||||
|
return new Date(year, month + 1, 0).getDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFirstDayOfWeek(year: number, month: number): number {
|
||||||
|
const day = new Date(year, month, 1).getDay()
|
||||||
|
return day === 0 ? 6 : day - 1 // Monday = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDisplay(d: Date | undefined): string {
|
||||||
|
if (!d) return '—'
|
||||||
|
const date = d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||||
|
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
return `${date}\u2009${time}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(n: number): string {
|
||||||
|
return String(n).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DateTimePicker({ value, onChange, label, placeholder, className }: DateTimePickerProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [viewYear, setViewYear] = useState(value?.getFullYear() ?? new Date().getFullYear())
|
||||||
|
const [viewMonth, setViewMonth] = useState(value?.getMonth() ?? new Date().getMonth())
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date | null>(value ?? null)
|
||||||
|
const [hour, setHour] = useState(value ? pad(value.getHours()) : pad(new Date().getHours()))
|
||||||
|
const [minute, setMinute] = useState(value ? pad(value.getMinutes()) : pad(new Date().getMinutes()))
|
||||||
|
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||||
|
|
||||||
|
// Sync when value changes externally
|
||||||
|
useEffect(() => {
|
||||||
|
if (value) {
|
||||||
|
setSelectedDate(value)
|
||||||
|
setHour(pad(value.getHours()))
|
||||||
|
setMinute(pad(value.getMinutes()))
|
||||||
|
setViewYear(value.getFullYear())
|
||||||
|
setViewMonth(value.getMonth())
|
||||||
|
}
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const reposition = useCallback(() => {
|
||||||
|
if (!triggerRef.current) return
|
||||||
|
const rect = triggerRef.current.getBoundingClientRect()
|
||||||
|
setPos({
|
||||||
|
top: rect.bottom + 4,
|
||||||
|
left: rect.left,
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
const id = requestAnimationFrame(reposition)
|
||||||
|
return () => cancelAnimationFrame(id)
|
||||||
|
}
|
||||||
|
}, [open, reposition])
|
||||||
|
|
||||||
|
// Close on Escape only — panel closes via Apply/Now buttons
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
function handleKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKey)
|
||||||
|
return () => document.removeEventListener('keydown', handleKey)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
function handleDone() {
|
||||||
|
if (selectedDate) {
|
||||||
|
const d = new Date(selectedDate)
|
||||||
|
d.setHours(parseInt(hour, 10) || 0, parseInt(minute, 10) || 0, 0, 0)
|
||||||
|
onChange?.(d)
|
||||||
|
}
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDayClick(day: number) {
|
||||||
|
const d = new Date(viewYear, viewMonth, day)
|
||||||
|
setSelectedDate(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNow() {
|
||||||
|
const now = new Date()
|
||||||
|
onChange?.(now)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevMonth() {
|
||||||
|
if (viewMonth === 0) { setViewMonth(11); setViewYear((y) => y - 1) }
|
||||||
|
else setViewMonth((m) => m - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMonth() {
|
||||||
|
if (viewMonth === 11) { setViewMonth(0); setViewYear((y) => y + 1) }
|
||||||
|
else setViewMonth((m) => m + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysInMonth = getDaysInMonth(viewYear, viewMonth)
|
||||||
|
const firstDay = getFirstDayOfWeek(viewYear, viewMonth)
|
||||||
|
const today = new Date()
|
||||||
|
|
||||||
|
const monthLabel = new Date(viewYear, viewMonth).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
date.getFullYear() +
|
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
||||||
'-' +
|
{label && <span className={styles.label}>{label}</span>}
|
||||||
pad(date.getMonth() + 1) +
|
<button
|
||||||
'-' +
|
ref={triggerRef}
|
||||||
pad(date.getDate()) +
|
type="button"
|
||||||
'T' +
|
className={styles.trigger}
|
||||||
pad(date.getHours()) +
|
onClick={() => setOpen(!open)}
|
||||||
':' +
|
>
|
||||||
pad(date.getMinutes())
|
{value ? formatDisplay(value) : (placeholder ?? '—')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && createPortal(
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className={styles.panel}
|
||||||
|
style={{ top: pos.top, left: pos.left }}
|
||||||
|
>
|
||||||
|
{/* Month navigation */}
|
||||||
|
<div className={styles.monthNav}>
|
||||||
|
<button type="button" className={styles.navBtn} onClick={prevMonth} aria-label="Previous month">◀</button>
|
||||||
|
<span className={styles.monthLabel}>{monthLabel}</span>
|
||||||
|
<button type="button" className={styles.navBtn} onClick={nextMonth} aria-label="Next month">▶</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar grid */}
|
||||||
|
<div className={styles.calendar}>
|
||||||
|
{DAYS.map((d) => (
|
||||||
|
<span key={d} className={styles.dayHeader}>{d}</span>
|
||||||
|
))}
|
||||||
|
{Array.from({ length: firstDay }, (_, i) => (
|
||||||
|
<span key={`pad-${i}`} className={styles.dayEmpty} />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: daysInMonth }, (_, i) => {
|
||||||
|
const day = i + 1
|
||||||
|
const isToday = viewYear === today.getFullYear() && viewMonth === today.getMonth() && day === today.getDate()
|
||||||
|
const isSelected = selectedDate && viewYear === selectedDate.getFullYear() && viewMonth === selectedDate.getMonth() && day === selectedDate.getDate()
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day}
|
||||||
|
type="button"
|
||||||
|
className={[styles.day, isToday ? styles.dayToday : '', isSelected ? styles.daySelected : ''].filter(Boolean).join(' ')}
|
||||||
|
onClick={() => handleDayClick(day)}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time selector */}
|
||||||
|
<div className={styles.timeRow}>
|
||||||
|
<span className={styles.timeLabel}>Time</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.timeInput}
|
||||||
|
value={hour}
|
||||||
|
onChange={(e) => setHour(e.target.value.replace(/\D/g, '').slice(0, 2))}
|
||||||
|
maxLength={2}
|
||||||
|
aria-label="Hour"
|
||||||
|
/>
|
||||||
|
<span className={styles.timeSep}>:</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.timeInput}
|
||||||
|
value={minute}
|
||||||
|
onChange={(e) => setMinute(e.target.value.replace(/\D/g, '').slice(0, 2))}
|
||||||
|
maxLength={2}
|
||||||
|
aria-label="Minute"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="button" className={styles.todayBtn} onClick={handleNow}>Now</button>
|
||||||
|
<button type="button" className={styles.doneBtn} onClick={handleDone} disabled={!selectedDate}>Apply</button>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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'
|
DateTimePicker.displayName = 'DateTimePicker'
|
||||||
|
|||||||
@@ -1,78 +1,11 @@
|
|||||||
/* ── Integrated readout cell ──────────────────────────────
|
.rangeRow {
|
||||||
First child of the ButtonGroup — styled as a recessed
|
|
||||||
instrument-panel display, not a clickable control. */
|
|
||||||
|
|
||||||
.readout {
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 4px 12px;
|
gap: 6px;
|
||||||
height: 28px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--bg-inset);
|
|
||||||
box-shadow: inset 0 1px 3px rgba(44, 37, 32, 0.06);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
cursor: default;
|
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .readout {
|
.rangeSep {
|
||||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Custom date picker panel ────────────────────────── */
|
|
||||||
|
|
||||||
.panel {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 12px;
|
|
||||||
min-width: 220px;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
z-index: 500;
|
|
||||||
animation: panelIn 150ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes panelIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.97);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.applyBtn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 5px 14px;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--amber);
|
|
||||||
color: #fff;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
color: var(--text-faint);
|
||||||
cursor: pointer;
|
flex-shrink: 0;
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.applyBtn:hover {
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.applyBtn:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,10 @@
|
|||||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
|
||||||
import styles from './TimeRangeDropdown.module.css'
|
import styles from './TimeRangeDropdown.module.css'
|
||||||
import { FilterPill } from '../FilterPill/FilterPill'
|
import { SegmentedTabs } from '../../composites/SegmentedTabs/SegmentedTabs'
|
||||||
import { ButtonGroup } from '../ButtonGroup/ButtonGroup'
|
|
||||||
import { DateTimePicker } from '../DateTimePicker/DateTimePicker'
|
import { DateTimePicker } from '../DateTimePicker/DateTimePicker'
|
||||||
import { computePresetRange } from '../../utils/timePresets'
|
import { computePresetRange } from '../../utils/timePresets'
|
||||||
import type { TimeRange } from '../../providers/GlobalFilterProvider'
|
import type { TimeRange } from '../../providers/GlobalFilterProvider'
|
||||||
|
|
||||||
function formatRangeLabel(range: TimeRange): string {
|
|
||||||
const start = range.preset ? computePresetRange(range.preset).start : range.start
|
|
||||||
|
|
||||||
const time = (d: Date) =>
|
|
||||||
d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })
|
|
||||||
const dateTime = (d: Date) =>
|
|
||||||
d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + '\u2009' + time(d)
|
|
||||||
|
|
||||||
// Preset ranges are open-ended ("since X"), so only show the start
|
|
||||||
if (range.preset) {
|
|
||||||
const now = new Date()
|
|
||||||
const sameDay =
|
|
||||||
start.getFullYear() === now.getFullYear() &&
|
|
||||||
start.getMonth() === now.getMonth() &&
|
|
||||||
start.getDate() === now.getDate()
|
|
||||||
return sameDay ? `${time(start)}\u2009\u2013\u2009now` : `${dateTime(start)}\u2009\u2013\u2009now`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom range: show both ends
|
|
||||||
const end = range.end
|
|
||||||
const sameDay =
|
|
||||||
start.getFullYear() === end.getFullYear() &&
|
|
||||||
start.getMonth() === end.getMonth() &&
|
|
||||||
start.getDate() === end.getDate()
|
|
||||||
|
|
||||||
if (sameDay) return `${time(start)}\u2009\u2013\u2009${time(end)}`
|
|
||||||
return `${dateTime(start)}\u2009\u2013\u2009${dateTime(end)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const PRESETS = [
|
const PRESETS = [
|
||||||
{ value: 'last-1h', label: '1h' },
|
{ value: 'last-1h', label: '1h' },
|
||||||
{ value: 'last-3h', label: '3h' },
|
{ value: 'last-3h', label: '3h' },
|
||||||
@@ -45,6 +14,8 @@ const PRESETS = [
|
|||||||
{ value: 'last-7d', label: '7d' },
|
{ value: 'last-7d', label: '7d' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const CUSTOM_VALUE = '__custom__'
|
||||||
|
|
||||||
interface TimeRangeDropdownProps {
|
interface TimeRangeDropdownProps {
|
||||||
value: TimeRange
|
value: TimeRange
|
||||||
onChange: (range: TimeRange) => void
|
onChange: (range: TimeRange) => void
|
||||||
@@ -52,112 +23,78 @@ interface TimeRangeDropdownProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TimeRangeDropdown({ value, onChange, className }: TimeRangeDropdownProps) {
|
export function TimeRangeDropdown({ value, onChange, className }: TimeRangeDropdownProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [customFrom, setCustomFrom] = useState<Date>(value.start)
|
||||||
const [customFrom, setCustomFrom] = useState<Date | null>(value.start)
|
const [customTo, setCustomTo] = useState<Date>(value.end)
|
||||||
const [customTo, setCustomTo] = useState<Date | null>(value.end)
|
const [toIsSet, setToIsSet] = useState(false)
|
||||||
const customRef = useRef<HTMLButtonElement>(null)
|
|
||||||
const panelRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [panelPos, setPanelPos] = useState({ top: 0, left: 0 })
|
|
||||||
const isCustom = value.preset === null || value.preset === 'custom'
|
const isCustom = value.preset === null || value.preset === 'custom'
|
||||||
|
const activeValue = isCustom ? CUSTOM_VALUE : (value.preset ?? 'last-1h')
|
||||||
|
|
||||||
const reposition = useCallback(() => {
|
// Sync local state when value changes from presets
|
||||||
if (!customRef.current) return
|
|
||||||
const rect = customRef.current.getBoundingClientRect()
|
|
||||||
const panelWidth = panelRef.current?.offsetWidth ?? 240
|
|
||||||
setPanelPos({
|
|
||||||
top: rect.bottom + window.scrollY + 8,
|
|
||||||
left: rect.right + window.scrollX - panelWidth,
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
setCustomFrom(value.start)
|
||||||
const id = requestAnimationFrame(reposition)
|
setCustomTo(value.end)
|
||||||
return () => cancelAnimationFrame(id)
|
}, [value.start, value.end])
|
||||||
}
|
|
||||||
}, [open, reposition])
|
|
||||||
|
|
||||||
useEffect(() => {
|
function handleTabChange(tabValue: string) {
|
||||||
if (!open) return
|
if (tabValue === CUSTOM_VALUE) return
|
||||||
function handleMouseDown(e: MouseEvent) {
|
setToIsSet(false)
|
||||||
if (
|
const range = computePresetRange(tabValue)
|
||||||
customRef.current?.contains(e.target as Node) ||
|
onChange({ ...range, preset: tabValue })
|
||||||
panelRef.current?.contains(e.target as Node)
|
}
|
||||||
) return
|
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
function handleKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Escape') setOpen(false)
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', handleMouseDown)
|
|
||||||
document.addEventListener('keydown', handleKey)
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleMouseDown)
|
|
||||||
document.removeEventListener('keydown', handleKey)
|
|
||||||
}
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
const rangeLabel = useMemo(() => formatRangeLabel(value), [value])
|
function handleFromChange(d: Date | null) {
|
||||||
|
if (!d) return
|
||||||
|
setCustomFrom(d)
|
||||||
|
// Only set preset to null; keep to-date as "now" if not explicitly set
|
||||||
|
if (toIsSet) {
|
||||||
|
onChange({ start: d, end: customTo, preset: null })
|
||||||
|
} else {
|
||||||
|
onChange({ start: d, end: new Date(), preset: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToChange(d: Date | null) {
|
||||||
|
if (!d) return
|
||||||
|
setCustomTo(d)
|
||||||
|
setToIsSet(true)
|
||||||
|
onChange({ start: customFrom, end: d, preset: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show "now" when to-date is not explicitly set
|
||||||
|
const showNow = !isCustom || !toIsSet
|
||||||
|
|
||||||
|
const rangeContent = (
|
||||||
|
<div className={styles.rangeRow}>
|
||||||
|
<DateTimePicker
|
||||||
|
value={isCustom ? customFrom : value.start}
|
||||||
|
onChange={handleFromChange}
|
||||||
|
/>
|
||||||
|
<span className={styles.rangeSep}>–</span>
|
||||||
|
{showNow ? (
|
||||||
|
<DateTimePicker
|
||||||
|
value={undefined}
|
||||||
|
onChange={handleToChange}
|
||||||
|
placeholder="now"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DateTimePicker
|
||||||
|
value={customTo}
|
||||||
|
onChange={handleToChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={className}>
|
||||||
<ButtonGroup className={className}>
|
<SegmentedTabs
|
||||||
{PRESETS.map((preset) => (
|
tabs={PRESETS}
|
||||||
<FilterPill
|
active={activeValue}
|
||||||
key={preset.value}
|
onChange={handleTabChange}
|
||||||
label={preset.label}
|
trailing={rangeContent}
|
||||||
active={value.preset === preset.value}
|
trailingValue={CUSTOM_VALUE}
|
||||||
onClick={() => {
|
/>
|
||||||
setOpen(false)
|
</div>
|
||||||
const range = computePresetRange(preset.value)
|
|
||||||
onChange({ ...range, preset: preset.value })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<FilterPill
|
|
||||||
ref={customRef}
|
|
||||||
label="Custom"
|
|
||||||
active={isCustom}
|
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
|
||||||
/>
|
|
||||||
<span className={styles.readout} aria-label="Active time range">
|
|
||||||
{rangeLabel}
|
|
||||||
</span>
|
|
||||||
</ButtonGroup>
|
|
||||||
|
|
||||||
{open && createPortal(
|
|
||||||
<div
|
|
||||||
ref={panelRef}
|
|
||||||
className={styles.panel}
|
|
||||||
style={{ top: panelPos.top, left: panelPos.left }}
|
|
||||||
role="dialog"
|
|
||||||
>
|
|
||||||
<DateTimePicker
|
|
||||||
label="From"
|
|
||||||
value={customFrom ?? undefined}
|
|
||||||
onChange={(d) => setCustomFrom(d)}
|
|
||||||
/>
|
|
||||||
<DateTimePicker
|
|
||||||
label="To"
|
|
||||||
value={customTo ?? undefined}
|
|
||||||
onChange={(d) => setCustomTo(d)}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.applyBtn}
|
|
||||||
disabled={!customFrom || !customTo}
|
|
||||||
onClick={() => {
|
|
||||||
if (customFrom && customTo) {
|
|
||||||
onChange({ start: customFrom, end: customTo, preset: null })
|
|
||||||
setOpen(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</button>
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,21 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navSubLink {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 2px 8px 2px 20px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navSubLink:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -4,10 +4,86 @@ import { PrimitivesSection } from './sections/PrimitivesSection'
|
|||||||
import { CompositesSection } from './sections/CompositesSection'
|
import { CompositesSection } from './sections/CompositesSection'
|
||||||
import { LayoutSection } from './sections/LayoutSection'
|
import { LayoutSection } from './sections/LayoutSection'
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_SECTIONS = [
|
||||||
{ label: 'Primitives', href: '#primitives' },
|
{
|
||||||
{ label: 'Composites', href: '#composites' },
|
label: 'Primitives',
|
||||||
{ label: 'Layout', href: '#layout' },
|
href: '#primitives',
|
||||||
|
components: [
|
||||||
|
{ label: 'Alert', href: '#alert' },
|
||||||
|
{ label: 'Avatar', href: '#avatar' },
|
||||||
|
{ label: 'Badge', href: '#badge' },
|
||||||
|
{ label: 'Button', href: '#button' },
|
||||||
|
{ label: 'Card', href: '#card' },
|
||||||
|
{ label: 'Checkbox', href: '#checkbox' },
|
||||||
|
{ label: 'CodeBlock', href: '#codeblock' },
|
||||||
|
{ label: 'Collapsible', href: '#collapsible' },
|
||||||
|
{ label: 'DateRangePicker', href: '#daterangepicker' },
|
||||||
|
{ label: 'DateTimePicker', href: '#datetimepicker' },
|
||||||
|
{ label: 'EmptyState', href: '#emptystate' },
|
||||||
|
{ label: 'FilterPill', href: '#filterpill' },
|
||||||
|
{ label: 'FormField', href: '#formfield' },
|
||||||
|
{ label: 'InfoCallout', href: '#infocallout' },
|
||||||
|
{ label: 'InlineEdit', href: '#inline-edit' },
|
||||||
|
{ label: 'Input', href: '#input' },
|
||||||
|
{ label: 'KeyboardHint', href: '#keyboardhint' },
|
||||||
|
{ label: 'Label', href: '#label' },
|
||||||
|
{ label: 'MonoText', href: '#monotext' },
|
||||||
|
{ label: 'Pagination', href: '#pagination' },
|
||||||
|
{ label: 'ProgressBar', href: '#progressbar' },
|
||||||
|
{ label: 'Radio', href: '#radio' },
|
||||||
|
{ label: 'SectionHeader', href: '#sectionheader' },
|
||||||
|
{ label: 'Select', href: '#select' },
|
||||||
|
{ label: 'Skeleton', href: '#skeleton' },
|
||||||
|
{ label: 'Sparkline', href: '#sparkline' },
|
||||||
|
{ label: 'Spinner', href: '#spinner' },
|
||||||
|
{ label: 'StatCard', href: '#statcard' },
|
||||||
|
{ label: 'StatusDot', href: '#statusdot' },
|
||||||
|
{ label: 'Tag', href: '#tag' },
|
||||||
|
{ label: 'Textarea', href: '#textarea' },
|
||||||
|
{ label: 'Toggle', href: '#toggle' },
|
||||||
|
{ label: 'Tooltip', href: '#tooltip' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Composites',
|
||||||
|
href: '#composites',
|
||||||
|
components: [
|
||||||
|
{ label: 'Accordion', href: '#accordion' },
|
||||||
|
{ label: 'AlertDialog', href: '#alertdialog' },
|
||||||
|
{ label: 'AreaChart', href: '#areachart' },
|
||||||
|
{ label: 'AvatarGroup', href: '#avatargroup' },
|
||||||
|
{ label: 'BarChart', href: '#barchart' },
|
||||||
|
{ label: 'Breadcrumb', href: '#breadcrumb' },
|
||||||
|
{ label: 'CommandPalette', href: '#commandpalette' },
|
||||||
|
{ label: 'ConfirmDialog', href: '#confirm-dialog' },
|
||||||
|
{ label: 'DataTable', href: '#datatable' },
|
||||||
|
{ label: 'DetailPanel', href: '#detailpanel' },
|
||||||
|
{ label: 'Dropdown', href: '#dropdown' },
|
||||||
|
{ label: 'EventFeed', href: '#eventfeed' },
|
||||||
|
{ label: 'FilterBar', href: '#filterbar' },
|
||||||
|
{ label: 'GroupCard', href: '#groupcard' },
|
||||||
|
{ label: 'LineChart', href: '#linechart' },
|
||||||
|
{ label: 'MenuItem', href: '#menuitem' },
|
||||||
|
{ label: 'Modal', href: '#modal' },
|
||||||
|
{ label: 'MultiSelect', href: '#multi-select' },
|
||||||
|
{ label: 'Popover', href: '#popover' },
|
||||||
|
{ label: 'ProcessorTimeline', href: '#processortimeline' },
|
||||||
|
{ label: 'SegmentedTabs', href: '#segmented-tabs' },
|
||||||
|
{ label: 'ShortcutsBar', href: '#shortcutsbar' },
|
||||||
|
{ label: 'Tabs', href: '#tabs' },
|
||||||
|
{ label: 'Toast', href: '#toast' },
|
||||||
|
{ label: 'TreeView', href: '#treeview' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Layout',
|
||||||
|
href: '#layout',
|
||||||
|
components: [
|
||||||
|
{ label: 'AppShell', href: '#appshell' },
|
||||||
|
{ label: 'Sidebar', href: '#sidebar' },
|
||||||
|
{ label: 'TopBar', href: '#topbar' },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function Inventory() {
|
export function Inventory() {
|
||||||
@@ -20,14 +96,16 @@ export function Inventory() {
|
|||||||
|
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
<nav className={styles.nav} aria-label="Component categories">
|
<nav className={styles.nav} aria-label="Component categories">
|
||||||
<div className={styles.navSection}>
|
{NAV_SECTIONS.map((section) => (
|
||||||
<span className={styles.navLabel}>Categories</span>
|
<div key={section.href} className={styles.navSection}>
|
||||||
{NAV_ITEMS.map((item) => (
|
<span className={styles.navLabel}>{section.label}</span>
|
||||||
<a key={item.href} href={item.href} className={styles.navLink}>
|
{section.components.map((component) => (
|
||||||
{item.label}
|
<a key={component.href} href={component.href} className={styles.navSubLink}>
|
||||||
</a>
|
{component.label}
|
||||||
))}
|
</a>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main className={styles.content}>
|
<main className={styles.content}>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
MultiSelect,
|
MultiSelect,
|
||||||
Popover,
|
Popover,
|
||||||
ProcessorTimeline,
|
ProcessorTimeline,
|
||||||
|
SegmentedTabs,
|
||||||
ShortcutsBar,
|
ShortcutsBar,
|
||||||
Tabs,
|
Tabs,
|
||||||
ToastProvider,
|
ToastProvider,
|
||||||
@@ -227,6 +228,7 @@ export function CompositesSection() {
|
|||||||
{ label: 'Agents', value: 'agents', count: 6 },
|
{ label: 'Agents', value: 'agents', count: 6 },
|
||||||
]
|
]
|
||||||
const [activeTab, setActiveTab] = useState('overview')
|
const [activeTab, setActiveTab] = useState('overview')
|
||||||
|
const [segTab, setSegTab] = useState('account')
|
||||||
|
|
||||||
// 21. TreeView
|
// 21. TreeView
|
||||||
const [selectedNode, setSelectedNode] = useState<string | undefined>('proc1')
|
const [selectedNode, setSelectedNode] = useState<string | undefined>('proc1')
|
||||||
@@ -636,6 +638,28 @@ export function CompositesSection() {
|
|||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|
||||||
|
{/* 19b. SegmentedTabs */}
|
||||||
|
<DemoCard
|
||||||
|
id="segmented-tabs"
|
||||||
|
title="SegmentedTabs"
|
||||||
|
description="Pill-style segmented tab bar with elevated active state. Same API as Tabs."
|
||||||
|
>
|
||||||
|
<div className={styles.demoAreaColumn} style={{ width: '100%' }}>
|
||||||
|
<SegmentedTabs
|
||||||
|
tabs={[
|
||||||
|
{ label: 'Account', value: 'account' },
|
||||||
|
{ label: 'Password', value: 'password' },
|
||||||
|
{ label: 'Notifications', value: 'notifications', count: 3 },
|
||||||
|
]}
|
||||||
|
active={segTab}
|
||||||
|
onChange={setSegTab}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
|
||||||
|
Active tab: <strong>{segTab}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DemoCard>
|
||||||
|
|
||||||
{/* 20. Toast */}
|
{/* 20. Toast */}
|
||||||
<DemoCard
|
<DemoCard
|
||||||
id="toast"
|
id="toast"
|
||||||
|
|||||||
Reference in New Issue
Block a user