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:
@@ -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 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'
|
||||
|
||||
@@ -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(
|
||||
<DateRangePicker
|
||||
value={{ start: new Date(), end: new Date() }}
|
||||
value={{ start: new Date('2026-03-19T10:00'), end: new Date('2026-03-19T11:00') }}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
)
|
||||
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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<InputHTMLAttributes<HTMLInputElement>, '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<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 (
|
||||
date.getFullYear() +
|
||||
'-' +
|
||||
pad(date.getMonth() + 1) +
|
||||
'-' +
|
||||
pad(date.getDate()) +
|
||||
'T' +
|
||||
pad(date.getHours()) +
|
||||
':' +
|
||||
pad(date.getMinutes())
|
||||
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
||||
{label && <span className={styles.label}>{label}</span>}
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Date | null>(value.start)
|
||||
const [customTo, setCustomTo] = useState<Date | null>(value.end)
|
||||
const customRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const [panelPos, setPanelPos] = useState({ top: 0, left: 0 })
|
||||
const [customFrom, setCustomFrom] = useState<Date>(value.start)
|
||||
const [customTo, setCustomTo] = useState<Date>(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 = (
|
||||
<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 (
|
||||
<>
|
||||
<ButtonGroup className={className}>
|
||||
{PRESETS.map((preset) => (
|
||||
<FilterPill
|
||||
key={preset.value}
|
||||
label={preset.label}
|
||||
active={value.preset === preset.value}
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
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,
|
||||
)}
|
||||
</>
|
||||
<div className={className}>
|
||||
<SegmentedTabs
|
||||
tabs={PRESETS}
|
||||
active={activeValue}
|
||||
onChange={handleTabChange}
|
||||
trailing={rangeContent}
|
||||
trailingValue={CUSTOM_VALUE}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
<div className={styles.body}>
|
||||
<nav className={styles.nav} aria-label="Component categories">
|
||||
<div className={styles.navSection}>
|
||||
<span className={styles.navLabel}>Categories</span>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<a key={item.href} href={item.href} className={styles.navLink}>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
{NAV_SECTIONS.map((section) => (
|
||||
<div key={section.href} className={styles.navSection}>
|
||||
<span className={styles.navLabel}>{section.label}</span>
|
||||
{section.components.map((component) => (
|
||||
<a key={component.href} href={component.href} className={styles.navSubLink}>
|
||||
{component.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<main className={styles.content}>
|
||||
|
||||
@@ -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<string | undefined>('proc1')
|
||||
@@ -636,6 +638,28 @@ export function CompositesSection() {
|
||||
</div>
|
||||
</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 */}
|
||||
<DemoCard
|
||||
id="toast"
|
||||
|
||||
Reference in New Issue
Block a user