diff --git a/COMPONENT_GUIDE.md b/COMPONENT_GUIDE.md index 10d62f6..e055aef 100644 --- a/COMPONENT_GUIDE.md +++ b/COMPONENT_GUIDE.md @@ -10,6 +10,7 @@ - Page-level attention banner → **Alert** - Temporary non-blocking feedback → **Toast** (via `useToast`) - Destructive action confirmation → **AlertDialog** +- Destructive action needing typed confirmation → **ConfirmDialog** - Generic dialog with custom content → **Modal** ### "I need a form input" @@ -19,6 +20,8 @@ - Yes/no with label → **Checkbox** - One of N options (≤5) → **RadioGroup** + **RadioItem** - One of N options (>5) → **Select** +- Select multiple from a list → **MultiSelect** +- Edit text inline without a form → **InlineEdit** - Date/time → **DateTimePicker** - Date range → **DateRangePicker** - Wrap any input with label/error/hint → **FormField** @@ -58,6 +61,7 @@ - Collapsible sections (standalone) → **Collapsible** - Multiple collapsible sections (one/many open) → **Accordion** - Tabbed content → **Tabs** +- Tab switching with pill/segment style → **SegmentedTabs** - Side panel inspector → **DetailPanel** - Section with title + action → **SectionHeader** - Empty content placeholder → **EmptyState** @@ -79,6 +83,7 @@ ### "I need filtering" - Filter pill/chip → **FilterPill** - Full filter bar with search → **FilterBar** +- Select multiple from a list → **MultiSelect** ## Composition Patterns @@ -157,6 +162,7 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/ | CodeBlock | primitive | Syntax-highlighted code/JSON display | | Collapsible | primitive | Single expand/collapse section | | 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 | | DateRangePicker | primitive | Date range selection with presets | | 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 | | FormField | primitive | Wrapper adding label, hint, error to any input | | 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 | | KeyboardHint | primitive | Keyboard shortcut display | | Label | primitive | Form label with optional required asterisk | | LineChart | composite | Time series line visualization | | MenuItem | composite | Sidebar navigation item with health/count | | 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) | | Pagination | primitive | Page navigation controls | | 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) | | RadioItem | primitive | Individual radio option within RadioGroup | | 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 | | ShortcutsBar | composite | Keyboard shortcuts reference bar | | Skeleton | primitive | Loading placeholder (text, circular, rectangular) | diff --git a/src/design-system/composites/SegmentedTabs/SegmentedTabs.module.css b/src/design-system/composites/SegmentedTabs/SegmentedTabs.module.css new file mode 100644 index 0000000..33fe866 --- /dev/null +++ b/src/design-system/composites/SegmentedTabs/SegmentedTabs.module.css @@ -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; +} diff --git a/src/design-system/composites/SegmentedTabs/SegmentedTabs.test.tsx b/src/design-system/composites/SegmentedTabs/SegmentedTabs.test.tsx new file mode 100644 index 0000000..e7ba098 --- /dev/null +++ b/src/design-system/composites/SegmentedTabs/SegmentedTabs.test.tsx @@ -0,0 +1,43 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { 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() + 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() + 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() + await user.click(screen.getByRole('tab', { name: /Roles/ })) + expect(onChange).toHaveBeenCalledWith('roles') + }) + + it('renders count badge when provided', () => { + render() + expect(screen.getByText('4')).toBeInTheDocument() + }) + + it('has tablist role on container', () => { + render() + expect(screen.getByRole('tablist')).toBeInTheDocument() + }) +}) diff --git a/src/design-system/composites/SegmentedTabs/SegmentedTabs.tsx b/src/design-system/composites/SegmentedTabs/SegmentedTabs.tsx new file mode 100644 index 0000000..09ad67b --- /dev/null +++ b/src/design-system/composites/SegmentedTabs/SegmentedTabs.tsx @@ -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(null) + const tabRefs = useRef>(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 ( +
+ {indicator && ( +
+ ) +} diff --git a/src/design-system/composites/index.ts b/src/design-system/composites/index.ts index 8b236b6..2273c02 100644 --- a/src/design-system/composites/index.ts +++ b/src/design-system/composites/index.ts @@ -25,6 +25,7 @@ export { Popover } from './Popover/Popover' export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline' export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline' export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar' +export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs' export { Tabs } from './Tabs/Tabs' export { ToastProvider, useToast } from './Toast/Toast' export { TreeView } from './TreeView/TreeView' diff --git a/src/design-system/primitives/DateRangePicker/DateRangePicker.test.tsx b/src/design-system/primitives/DateRangePicker/DateRangePicker.test.tsx index f433261..4c6e5a6 100644 --- a/src/design-system/primitives/DateRangePicker/DateRangePicker.test.tsx +++ b/src/design-system/primitives/DateRangePicker/DateRangePicker.test.tsx @@ -4,15 +4,17 @@ import userEvent from '@testing-library/user-event' import { DateRangePicker } from './DateRangePicker' describe('DateRangePicker', () => { - it('renders two datetime inputs', () => { - const { container } = render( + it('renders two datetime picker triggers', () => { + render( {}} />, ) - const inputs = container.querySelectorAll('input[type="datetime-local"]') - expect(inputs.length).toBe(2) + // DateTimePicker renders button triggers with formatted date text + 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', () => { diff --git a/src/design-system/primitives/DateTimePicker/DateTimePicker.module.css b/src/design-system/primitives/DateTimePicker/DateTimePicker.module.css index 338df51..f679662 100644 --- a/src/design-system/primitives/DateTimePicker/DateTimePicker.module.css +++ b/src/design-system/primitives/DateTimePicker/DateTimePicker.module.css @@ -12,26 +12,217 @@ letter-spacing: 0.5px; } -.input { - width: 100%; - padding: 6px 10px; +.trigger { + padding: 0 4px; + 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-radius: var(--radius-sm); background: var(--bg-raised); color: var(--text-primary); font-family: var(--font-mono); - font-size: 12px; + font-size: 13px; + text-align: center; outline: none; - transition: border-color 0.15s, box-shadow 0.15s; - cursor: pointer; } -.input:focus { +.timeInput:focus { 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 { - opacity: 0.5; - cursor: pointer; +.timeSep { + font-size: 14px; + 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; } diff --git a/src/design-system/primitives/DateTimePicker/DateTimePicker.tsx b/src/design-system/primitives/DateTimePicker/DateTimePicker.tsx index afbfc38..e9b7ca5 100644 --- a/src/design-system/primitives/DateTimePicker/DateTimePicker.tsx +++ b/src/design-system/primitives/DateTimePicker/DateTimePicker.tsx @@ -1,51 +1,204 @@ +import { useState, useRef, useEffect, useCallback } from 'react' +import { createPortal } from 'react-dom' import styles from './DateTimePicker.module.css' -import { forwardRef, type InputHTMLAttributes } from 'react' -interface DateTimePickerProps extends Omit, 'type' | 'value' | 'onChange'> { +interface DateTimePickerProps { value?: Date onChange?: (date: Date | null) => void label?: string + placeholder?: string + className?: string } -function toLocalDateTimeString(date: Date): string { - const pad = (n: number) => String(n).padStart(2, '0') +const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'] + +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(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(null) + const panelRef = useRef(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 ( - date.getFullYear() + - '-' + - pad(date.getMonth() + 1) + - '-' + - pad(date.getDate()) + - 'T' + - pad(date.getHours()) + - ':' + - pad(date.getMinutes()) +
+ {label && {label}} + + + {open && createPortal( +
+ {/* Month navigation */} +
+ + {monthLabel} + +
+ + {/* Calendar grid */} +
+ {DAYS.map((d) => ( + {d} + ))} + {Array.from({ length: firstDay }, (_, i) => ( + + ))} + {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 ( + + ) + })} +
+ + {/* Time selector */} +
+ Time + setHour(e.target.value.replace(/\D/g, '').slice(0, 2))} + maxLength={2} + aria-label="Hour" + /> + : + setMinute(e.target.value.replace(/\D/g, '').slice(0, 2))} + maxLength={2} + aria-label="Minute" + /> +
+ + {/* Actions */} +
+ + +
+
, + document.body, + )} +
) } -export const DateTimePicker = forwardRef( - ({ value, onChange, label, className, ...rest }, ref) => { - const inputValue = value ? toLocalDateTimeString(value) : '' - - function handleChange(e: React.ChangeEvent) { - if (!onChange) return - const v = e.target.value - onChange(v ? new Date(v) : null) - } - - return ( -
- {label && } - -
- ) - }, -) - DateTimePicker.displayName = 'DateTimePicker' diff --git a/src/design-system/primitives/TimeRangeDropdown/TimeRangeDropdown.module.css b/src/design-system/primitives/TimeRangeDropdown/TimeRangeDropdown.module.css index b1c9a98..8798325 100644 --- a/src/design-system/primitives/TimeRangeDropdown/TimeRangeDropdown.module.css +++ b/src/design-system/primitives/TimeRangeDropdown/TimeRangeDropdown.module.css @@ -1,78 +1,11 @@ -/* ── Integrated readout cell ────────────────────────────── - First child of the ButtonGroup — styled as a recessed - instrument-panel display, not a clickable control. */ - -.readout { +.rangeRow { display: inline-flex; align-items: center; - padding: 4px 12px; - 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; + gap: 6px; } -[data-theme="dark"] .readout { - 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); +.rangeSep { font-size: 12px; - font-weight: 600; - cursor: pointer; - transition: opacity 0.15s; -} - -.applyBtn:hover { - opacity: 0.85; -} - -.applyBtn:disabled { - opacity: 0.4; - cursor: not-allowed; + color: var(--text-faint); + flex-shrink: 0; } diff --git a/src/design-system/primitives/TimeRangeDropdown/TimeRangeDropdown.tsx b/src/design-system/primitives/TimeRangeDropdown/TimeRangeDropdown.tsx index 46466f8..f7b5d60 100644 --- a/src/design-system/primitives/TimeRangeDropdown/TimeRangeDropdown.tsx +++ b/src/design-system/primitives/TimeRangeDropdown/TimeRangeDropdown.tsx @@ -1,41 +1,10 @@ -import { useState, useRef, useEffect, useCallback, useMemo } from 'react' -import { createPortal } from 'react-dom' +import { useState, useEffect } from 'react' import styles from './TimeRangeDropdown.module.css' -import { FilterPill } from '../FilterPill/FilterPill' -import { ButtonGroup } from '../ButtonGroup/ButtonGroup' +import { SegmentedTabs } from '../../composites/SegmentedTabs/SegmentedTabs' import { DateTimePicker } from '../DateTimePicker/DateTimePicker' import { computePresetRange } from '../../utils/timePresets' 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 = [ { value: 'last-1h', label: '1h' }, { value: 'last-3h', label: '3h' }, @@ -45,6 +14,8 @@ const PRESETS = [ { value: 'last-7d', label: '7d' }, ] +const CUSTOM_VALUE = '__custom__' + interface TimeRangeDropdownProps { value: TimeRange onChange: (range: TimeRange) => void @@ -52,112 +23,78 @@ interface TimeRangeDropdownProps { } export function TimeRangeDropdown({ value, onChange, className }: TimeRangeDropdownProps) { - const [open, setOpen] = useState(false) - const [customFrom, setCustomFrom] = useState(value.start) - const [customTo, setCustomTo] = useState(value.end) - const customRef = useRef(null) - const panelRef = useRef(null) - const [panelPos, setPanelPos] = useState({ top: 0, left: 0 }) + const [customFrom, setCustomFrom] = useState(value.start) + const [customTo, setCustomTo] = useState(value.end) + const [toIsSet, setToIsSet] = useState(false) + const isCustom = value.preset === null || value.preset === 'custom' + const activeValue = isCustom ? CUSTOM_VALUE : (value.preset ?? 'last-1h') - const reposition = useCallback(() => { - 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, - }) - }, []) - + // Sync local state when value changes from presets useEffect(() => { - if (open) { - const id = requestAnimationFrame(reposition) - return () => cancelAnimationFrame(id) - } - }, [open, reposition]) + setCustomFrom(value.start) + setCustomTo(value.end) + }, [value.start, value.end]) - useEffect(() => { - if (!open) return - function handleMouseDown(e: MouseEvent) { - if ( - customRef.current?.contains(e.target as Node) || - 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]) + function handleTabChange(tabValue: string) { + if (tabValue === CUSTOM_VALUE) return + setToIsSet(false) + const range = computePresetRange(tabValue) + onChange({ ...range, preset: tabValue }) + } - 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 = ( +
+ + + {showNow ? ( + + ) : ( + + )} +
+ ) return ( - <> - - {PRESETS.map((preset) => ( - { - setOpen(false) - const range = computePresetRange(preset.value) - onChange({ ...range, preset: preset.value }) - }} - /> - ))} - setOpen((prev) => !prev)} - /> - - {rangeLabel} - - - - {open && createPortal( -
- setCustomFrom(d)} - /> - setCustomTo(d)} - /> - -
, - document.body, - )} - +
+ +
) } diff --git a/src/pages/Inventory/Inventory.module.css b/src/pages/Inventory/Inventory.module.css index fc58819..463f7a6 100644 --- a/src/pages/Inventory/Inventory.module.css +++ b/src/pages/Inventory/Inventory.module.css @@ -81,6 +81,21 @@ 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 { flex: 1; min-width: 0; diff --git a/src/pages/Inventory/Inventory.tsx b/src/pages/Inventory/Inventory.tsx index a57c1eb..a215ddb 100644 --- a/src/pages/Inventory/Inventory.tsx +++ b/src/pages/Inventory/Inventory.tsx @@ -4,10 +4,86 @@ import { PrimitivesSection } from './sections/PrimitivesSection' import { CompositesSection } from './sections/CompositesSection' import { LayoutSection } from './sections/LayoutSection' -const NAV_ITEMS = [ - { label: 'Primitives', href: '#primitives' }, - { label: 'Composites', href: '#composites' }, - { label: 'Layout', href: '#layout' }, +const NAV_SECTIONS = [ + { + label: 'Primitives', + 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() { @@ -20,14 +96,16 @@ export function Inventory() {
diff --git a/src/pages/Inventory/sections/CompositesSection.tsx b/src/pages/Inventory/sections/CompositesSection.tsx index c2c8d44..bbc5959 100644 --- a/src/pages/Inventory/sections/CompositesSection.tsx +++ b/src/pages/Inventory/sections/CompositesSection.tsx @@ -21,6 +21,7 @@ import { MultiSelect, Popover, ProcessorTimeline, + SegmentedTabs, ShortcutsBar, Tabs, ToastProvider, @@ -227,6 +228,7 @@ export function CompositesSection() { { label: 'Agents', value: 'agents', count: 6 }, ] const [activeTab, setActiveTab] = useState('overview') + const [segTab, setSegTab] = useState('account') // 21. TreeView const [selectedNode, setSelectedNode] = useState('proc1') @@ -636,6 +638,28 @@ export function CompositesSection() {
+ {/* 19b. SegmentedTabs */} + +
+ +
+ Active tab: {segTab} +
+
+
+ {/* 20. Toast */}