diff --git a/src/design-system/layout/TopBar/TopBar.module.css b/src/design-system/layout/TopBar/TopBar.module.css index fd52c21..59e4e2a 100644 --- a/src/design-system/layout/TopBar/TopBar.module.css +++ b/src/design-system/layout/TopBar/TopBar.module.css @@ -22,7 +22,7 @@ flex-shrink: 0; } -/* Center search trigger */ +/* Search trigger */ .search { display: flex; align-items: center; @@ -36,9 +36,9 @@ font-family: var(--font-body); cursor: pointer; transition: border-color 0.15s; - min-width: 180px; - flex: 1; - max-width: 280px; + width: 200px; + flex-shrink: 1; + min-width: 120px; text-align: left; } diff --git a/src/design-system/layout/TopBar/TopBar.tsx b/src/design-system/layout/TopBar/TopBar.tsx index 3d0df84..911bf20 100644 --- a/src/design-system/layout/TopBar/TopBar.tsx +++ b/src/design-system/layout/TopBar/TopBar.tsx @@ -39,23 +39,7 @@ export function TopBar({ {/* Left: Breadcrumb */} - {/* Filters: time range + status pills */} -
- - {STATUS_PILLS.map(({ status, label }) => ( - globalFilters.toggleStatus(status)} - /> - ))} -
- - {/* Center: Search trigger */} + {/* Search trigger */} + {/* Status pills */} +
+ {STATUS_PILLS.map(({ status, label }) => ( + globalFilters.toggleStatus(status)} + /> + ))} +
+ + {/* Time range pills */} + + {/* Right: env badge, user */}
{environment && ( diff --git a/src/design-system/primitives/ButtonGroup/ButtonGroup.module.css b/src/design-system/primitives/ButtonGroup/ButtonGroup.module.css new file mode 100644 index 0000000..bc9e497 --- /dev/null +++ b/src/design-system/primitives/ButtonGroup/ButtonGroup.module.css @@ -0,0 +1,60 @@ +.group { + display: inline-flex; + isolation: isolate; +} + +/* Horizontal (default) */ +.horizontal { + flex-direction: row; +} + +.horizontal > :global(*) { + border-radius: 0; + margin-left: -1px; + position: relative; +} + +.horizontal > :global(*:first-child) { + border-radius: var(--radius-sm) 0 0 var(--radius-sm); + margin-left: 0; +} + +.horizontal > :global(*:last-child) { + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} + +.horizontal > :global(*:only-child) { + border-radius: var(--radius-sm); +} + +/* Vertical */ +.vertical { + flex-direction: column; +} + +.vertical > :global(*) { + border-radius: 0; + margin-top: -1px; + position: relative; +} + +.vertical > :global(*:first-child) { + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + margin-top: 0; +} + +.vertical > :global(*:last-child) { + border-radius: 0 0 var(--radius-sm) var(--radius-sm); +} + +.vertical > :global(*:only-child) { + border-radius: var(--radius-sm); +} + +/* Active/hovered items sit above siblings so their borders win */ +.group > :global(*:hover), +.group > :global(*:focus-visible), +.group > :global(*[data-active="true"]), +.group > :global(*.active) { + z-index: 1; +} diff --git a/src/design-system/primitives/ButtonGroup/ButtonGroup.tsx b/src/design-system/primitives/ButtonGroup/ButtonGroup.tsx new file mode 100644 index 0000000..98e73dc --- /dev/null +++ b/src/design-system/primitives/ButtonGroup/ButtonGroup.tsx @@ -0,0 +1,23 @@ +import { type ReactNode } from 'react' +import styles from './ButtonGroup.module.css' + +interface ButtonGroupProps { + children: ReactNode + orientation?: 'horizontal' | 'vertical' + className?: string +} + +export function ButtonGroup({ + children, + orientation = 'horizontal', + className, +}: ButtonGroupProps) { + return ( +
+ {children} +
+ ) +} diff --git a/src/design-system/primitives/FilterPill/FilterPill.tsx b/src/design-system/primitives/FilterPill/FilterPill.tsx index 90173f6..8769073 100644 --- a/src/design-system/primitives/FilterPill/FilterPill.tsx +++ b/src/design-system/primitives/FilterPill/FilterPill.tsx @@ -1,3 +1,4 @@ +import { forwardRef } from 'react' import styles from './FilterPill.module.css' interface FilterPillProps { @@ -10,33 +11,37 @@ interface FilterPillProps { className?: string } -export function FilterPill({ - label, - count, - active = false, - dot = false, - dotColor, - onClick, - className, -}: FilterPillProps) { - const classes = [ - styles.pill, - active ? styles.active : '', - className ?? '', - ].filter(Boolean).join(' ') +export const FilterPill = forwardRef( + ({ + label, + count, + active = false, + dot = false, + dotColor, + onClick, + className, + }, ref) => { + const classes = [ + styles.pill, + active ? styles.active : '', + className ?? '', + ].filter(Boolean).join(' ') - return ( - - ) -} + return ( + + ) + }, +) + +FilterPill.displayName = 'FilterPill' diff --git a/src/design-system/primitives/TimeRangeDropdown/TimeRangeDropdown.module.css b/src/design-system/primitives/TimeRangeDropdown/TimeRangeDropdown.module.css index 5f7a8f2..b1c9a98 100644 --- a/src/design-system/primitives/TimeRangeDropdown/TimeRangeDropdown.module.css +++ b/src/design-system/primitives/TimeRangeDropdown/TimeRangeDropdown.module.css @@ -1,45 +1,78 @@ -.trigger { - display: flex; +/* ── Integrated readout cell ────────────────────────────── + First child of the ButtonGroup — styled as a recessed + instrument-panel display, not a clickable control. */ + +.readout { + display: inline-flex; align-items: center; - gap: 4px; - padding: 4px 10px; + padding: 4px 12px; height: 28px; border: 1px solid var(--border); - border-radius: var(--radius-sm); - background: var(--bg-raised); - color: var(--amber, var(--warning)); + 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 { + 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-weight: 600; cursor: pointer; - transition: border-color 0.15s, background 0.15s; - white-space: nowrap; + transition: opacity 0.15s; } -.trigger:hover { - border-color: var(--text-faint); - background: var(--bg-surface); +.applyBtn:hover { + opacity: 0.85; } -.icon { - font-size: 13px; - line-height: 1; -} - -.label { - line-height: 1; -} - -.caret { - font-size: 9px; - opacity: 0.7; - line-height: 1; -} - -.presetList { - display: flex; - flex-direction: column; - gap: 4px; - padding: 8px; - min-width: 100px; +.applyBtn:disabled { + opacity: 0.4; + cursor: not-allowed; } diff --git a/src/design-system/primitives/TimeRangeDropdown/TimeRangeDropdown.tsx b/src/design-system/primitives/TimeRangeDropdown/TimeRangeDropdown.tsx index b3fa356..46466f8 100644 --- a/src/design-system/primitives/TimeRangeDropdown/TimeRangeDropdown.tsx +++ b/src/design-system/primitives/TimeRangeDropdown/TimeRangeDropdown.tsx @@ -1,15 +1,46 @@ +import { useState, useRef, useEffect, useCallback, useMemo } from 'react' +import { createPortal } from 'react-dom' import styles from './TimeRangeDropdown.module.css' -import { Popover } from '../../composites/Popover/Popover' import { FilterPill } from '../FilterPill/FilterPill' -import { computePresetRange, PRESET_SHORT_LABELS } from '../../utils/timePresets' +import { ButtonGroup } from '../ButtonGroup/ButtonGroup' +import { DateTimePicker } from '../DateTimePicker/DateTimePicker' +import { computePresetRange } from '../../utils/timePresets' import type { TimeRange } from '../../providers/GlobalFilterProvider' -const DROPDOWN_PRESETS = [ +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' }, { value: 'last-6h', label: '6h' }, { value: 'today', label: 'Today' }, - { value: 'shift', label: 'Shift' }, { value: 'last-24h', label: '24h' }, { value: 'last-7d', label: '7d' }, ] @@ -21,35 +52,112 @@ interface TimeRangeDropdownProps { } export function TimeRangeDropdown({ value, onChange, className }: TimeRangeDropdownProps) { - const activeLabel = value.preset ? (PRESET_SHORT_LABELS[value.preset] ?? value.preset) : 'Custom' + 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 isCustom = value.preset === null || value.preset === 'custom' + + 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, + }) + }, []) + + useEffect(() => { + if (open) { + const id = requestAnimationFrame(reposition) + return () => cancelAnimationFrame(id) + } + }, [open, reposition]) + + 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]) + + const rangeLabel = useMemo(() => formatRangeLabel(value), [value]) return ( - - - {activeLabel} - - - } - content={ -
- {DROPDOWN_PRESETS.map((preset) => ( - { - const range = computePresetRange(preset.value) - onChange({ ...range, preset: preset.value }) - }} - /> - ))} -
- } - /> + <> + + {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/design-system/primitives/index.ts b/src/design-system/primitives/index.ts index 97aed32..5c3e4c7 100644 --- a/src/design-system/primitives/index.ts +++ b/src/design-system/primitives/index.ts @@ -2,6 +2,7 @@ export { Alert } from './Alert/Alert' export { Avatar } from './Avatar/Avatar' export { Badge } from './Badge/Badge' export { Button } from './Button/Button' +export { ButtonGroup } from './ButtonGroup/ButtonGroup' export { Card } from './Card/Card' export { Checkbox } from './Checkbox/Checkbox' export { CodeBlock } from './CodeBlock/CodeBlock' diff --git a/src/design-system/providers/GlobalFilterProvider.tsx b/src/design-system/providers/GlobalFilterProvider.tsx index 024db38..ce52388 100644 --- a/src/design-system/providers/GlobalFilterProvider.tsx +++ b/src/design-system/providers/GlobalFilterProvider.tsx @@ -20,7 +20,7 @@ interface GlobalFilterContextValue { const GlobalFilterContext = createContext(null) -const DEFAULT_PRESET = 'last-3h' +const DEFAULT_PRESET = 'last-1h' function getDefaultTimeRange(): TimeRange { const { start, end } = computePresetRange(DEFAULT_PRESET)