feat: add ButtonGroup primitive and redesign TopBar time range selector
All checks were successful
Build & Publish / publish (push) Successful in 46s
All checks were successful
Build & Publish / publish (push) Successful in 46s
Replace the TimeRangeDropdown popover with inline FilterPills inside a new ButtonGroup component. The ButtonGroup merges adjacent children into a single visual strip with shared borders and rounded end-caps. The time readout is now an integrated inset display cell at the right end of the group. Preset ranges show "HH:MM – now"; custom ranges show both timestamps. Default changed from 3h to 1h. TopBar reordered to: Breadcrumb | Search | Status pills | Time pills | Right. FilterPill upgraded to forwardRef with data-active attribute. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,23 +39,7 @@ export function TopBar({
|
||||
{/* Left: Breadcrumb */}
|
||||
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} />
|
||||
|
||||
{/* Filters: time range + status pills */}
|
||||
<div className={styles.filters}>
|
||||
<TimeRangeDropdown
|
||||
value={globalFilters.timeRange}
|
||||
onChange={globalFilters.setTimeRange}
|
||||
/>
|
||||
{STATUS_PILLS.map(({ status, label }) => (
|
||||
<FilterPill
|
||||
key={status}
|
||||
label={label}
|
||||
active={globalFilters.statusFilters.has(status)}
|
||||
onClick={() => globalFilters.toggleStatus(status)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Center: Search trigger */}
|
||||
{/* Search trigger */}
|
||||
<button
|
||||
className={styles.search}
|
||||
onClick={() => commandPalette.setOpen(true)}
|
||||
@@ -72,6 +56,24 @@ export function TopBar({
|
||||
<span className={styles.kbd}>Ctrl+K</span>
|
||||
</button>
|
||||
|
||||
{/* Status pills */}
|
||||
<div className={styles.filters}>
|
||||
{STATUS_PILLS.map(({ status, label }) => (
|
||||
<FilterPill
|
||||
key={status}
|
||||
label={label}
|
||||
active={globalFilters.statusFilters.has(status)}
|
||||
onClick={() => globalFilters.toggleStatus(status)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Time range pills */}
|
||||
<TimeRangeDropdown
|
||||
value={globalFilters.timeRange}
|
||||
onChange={globalFilters.setTimeRange}
|
||||
/>
|
||||
|
||||
{/* Right: env badge, user */}
|
||||
<div className={styles.right}>
|
||||
{environment && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
23
src/design-system/primitives/ButtonGroup/ButtonGroup.tsx
Normal file
23
src/design-system/primitives/ButtonGroup/ButtonGroup.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={`${styles.group} ${styles[orientation]} ${className ?? ''}`}
|
||||
role="group"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { forwardRef } from 'react'
|
||||
import styles from './FilterPill.module.css'
|
||||
|
||||
interface FilterPillProps {
|
||||
@@ -10,7 +11,8 @@ interface FilterPillProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FilterPill({
|
||||
export const FilterPill = forwardRef<HTMLButtonElement, FilterPillProps>(
|
||||
({
|
||||
label,
|
||||
count,
|
||||
active = false,
|
||||
@@ -18,7 +20,7 @@ export function FilterPill({
|
||||
dotColor,
|
||||
onClick,
|
||||
className,
|
||||
}: FilterPillProps) {
|
||||
}, ref) => {
|
||||
const classes = [
|
||||
styles.pill,
|
||||
active ? styles.active : '',
|
||||
@@ -26,7 +28,7 @@ export function FilterPill({
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
return (
|
||||
<button className={classes} onClick={onClick} type="button">
|
||||
<button ref={ref} className={classes} onClick={onClick} type="button" data-active={active || undefined}>
|
||||
{dot && (
|
||||
<span
|
||||
className={styles.dot}
|
||||
@@ -39,4 +41,7 @@ export function FilterPill({
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
FilterPill.displayName = 'FilterPill'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<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 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 (
|
||||
<Popover
|
||||
className={className}
|
||||
position="bottom"
|
||||
align="start"
|
||||
trigger={
|
||||
<button className={styles.trigger} type="button" aria-label="Select time range">
|
||||
<span className={styles.icon} aria-hidden="true">⏱</span>
|
||||
<span className={styles.label}>{activeLabel}</span>
|
||||
<span className={styles.caret} aria-hidden="true">▾</span>
|
||||
</button>
|
||||
}
|
||||
content={
|
||||
<div className={styles.presetList}>
|
||||
{DROPDOWN_PRESETS.map((preset) => (
|
||||
<>
|
||||
<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 })
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
<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,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -20,7 +20,7 @@ interface GlobalFilterContextValue {
|
||||
|
||||
const GlobalFilterContext = createContext<GlobalFilterContextValue | null>(null)
|
||||
|
||||
const DEFAULT_PRESET = 'last-3h'
|
||||
const DEFAULT_PRESET = 'last-1h'
|
||||
|
||||
function getDefaultTimeRange(): TimeRange {
|
||||
const { start, end } = computePresetRange(DEFAULT_PRESET)
|
||||
|
||||
Reference in New Issue
Block a user