feat: add ButtonGroup primitive and redesign TopBar time range selector
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:
hsiegeln
2026-03-18 22:18:57 +01:00
parent 0a3d568a47
commit f16c5a9575
9 changed files with 348 additions and 116 deletions

View File

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

View File

@@ -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 && (

View File

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

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

View File

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

View File

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

View File

@@ -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">&#9201;</span>
<span className={styles.label}>{activeLabel}</span>
<span className={styles.caret} aria-hidden="true">&#9662;</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,
)}
</>
)
}

View File

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

View File

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