feat: add SegmentedTabs, custom DateTimePicker, redesign time range selector
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:
hsiegeln
2026-03-19 11:39:54 +01:00
parent 8418b89a77
commit daf53ad499
13 changed files with 838 additions and 272 deletions

View File

@@ -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) |

View File

@@ -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;
}

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 { 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()
})
})

View 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>
)
}

View File

@@ -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'

View File

@@ -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', () => {

View File

@@ -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;
} }

View File

@@ -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">&#9664;</button>
<span className={styles.monthLabel}>{monthLabel}</span>
<button type="button" className={styles.navBtn} onClick={nextMonth} aria-label="Next month">&#9654;</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'

View File

@@ -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;
} }

View File

@@ -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,
)}
</>
) )
} }

View File

@@ -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;

View File

@@ -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}>

View File

@@ -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"