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; flex-shrink: 0;
} }
/* Center search trigger */ /* Search trigger */
.search { .search {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -36,9 +36,9 @@
font-family: var(--font-body); font-family: var(--font-body);
cursor: pointer; cursor: pointer;
transition: border-color 0.15s; transition: border-color 0.15s;
min-width: 180px; width: 200px;
flex: 1; flex-shrink: 1;
max-width: 280px; min-width: 120px;
text-align: left; text-align: left;
} }

View File

@@ -39,23 +39,7 @@ export function TopBar({
{/* Left: Breadcrumb */} {/* Left: Breadcrumb */}
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} /> <Breadcrumb items={breadcrumb} className={styles.breadcrumb} />
{/* Filters: time range + status pills */} {/* Search trigger */}
<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 */}
<button <button
className={styles.search} className={styles.search}
onClick={() => commandPalette.setOpen(true)} onClick={() => commandPalette.setOpen(true)}
@@ -72,6 +56,24 @@ export function TopBar({
<span className={styles.kbd}>Ctrl+K</span> <span className={styles.kbd}>Ctrl+K</span>
</button> </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 */} {/* Right: env badge, user */}
<div className={styles.right}> <div className={styles.right}>
{environment && ( {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' import styles from './FilterPill.module.css'
interface FilterPillProps { interface FilterPillProps {
@@ -10,33 +11,37 @@ interface FilterPillProps {
className?: string className?: string
} }
export function FilterPill({ export const FilterPill = forwardRef<HTMLButtonElement, FilterPillProps>(
label, ({
count, label,
active = false, count,
dot = false, active = false,
dotColor, dot = false,
onClick, dotColor,
className, onClick,
}: FilterPillProps) { className,
const classes = [ }, ref) => {
styles.pill, const classes = [
active ? styles.active : '', styles.pill,
className ?? '', active ? styles.active : '',
].filter(Boolean).join(' ') className ?? '',
].filter(Boolean).join(' ')
return ( return (
<button className={classes} onClick={onClick} type="button"> <button ref={ref} className={classes} onClick={onClick} type="button" data-active={active || undefined}>
{dot && ( {dot && (
<span <span
className={styles.dot} className={styles.dot}
style={dotColor ? { background: dotColor } : undefined} style={dotColor ? { background: dotColor } : undefined}
/> />
)} )}
<span className={styles.label}>{label}</span> <span className={styles.label}>{label}</span>
{count !== undefined && ( {count !== undefined && (
<span className={styles.count}>{count}</span> <span className={styles.count}>{count}</span>
)} )}
</button> </button>
) )
} },
)
FilterPill.displayName = 'FilterPill'

View File

@@ -1,45 +1,78 @@
.trigger { /* ── Integrated readout cell ──────────────────────────────
display: flex; First child of the ButtonGroup — styled as a recessed
instrument-panel display, not a clickable control. */
.readout {
display: inline-flex;
align-items: center; align-items: center;
gap: 4px; padding: 4px 12px;
padding: 4px 10px;
height: 28px; height: 28px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); background: var(--bg-inset);
background: var(--bg-raised); box-shadow: inset 0 1px 3px rgba(44, 37, 32, 0.06);
color: var(--amber, var(--warning));
font-family: var(--font-mono); 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-size: 12px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: border-color 0.15s, background 0.15s; transition: opacity 0.15s;
white-space: nowrap;
} }
.trigger:hover { .applyBtn:hover {
border-color: var(--text-faint); opacity: 0.85;
background: var(--bg-surface);
} }
.icon { .applyBtn:disabled {
font-size: 13px; opacity: 0.4;
line-height: 1; cursor: not-allowed;
}
.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;
} }

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 styles from './TimeRangeDropdown.module.css'
import { Popover } from '../../composites/Popover/Popover'
import { FilterPill } from '../FilterPill/FilterPill' 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' 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-1h', label: '1h' },
{ value: 'last-3h', label: '3h' }, { value: 'last-3h', label: '3h' },
{ value: 'last-6h', label: '6h' }, { value: 'last-6h', label: '6h' },
{ value: 'today', label: 'Today' }, { value: 'today', label: 'Today' },
{ value: 'shift', label: 'Shift' },
{ value: 'last-24h', label: '24h' }, { value: 'last-24h', label: '24h' },
{ value: 'last-7d', label: '7d' }, { value: 'last-7d', label: '7d' },
] ]
@@ -21,35 +52,112 @@ interface TimeRangeDropdownProps {
} }
export function TimeRangeDropdown({ value, onChange, className }: 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 ( return (
<Popover <>
className={className} <ButtonGroup className={className}>
position="bottom" {PRESETS.map((preset) => (
align="start" <FilterPill
trigger={ key={preset.value}
<button className={styles.trigger} type="button" aria-label="Select time range"> label={preset.label}
<span className={styles.icon} aria-hidden="true">&#9201;</span> active={value.preset === preset.value}
<span className={styles.label}>{activeLabel}</span> onClick={() => {
<span className={styles.caret} aria-hidden="true">&#9662;</span> setOpen(false)
</button> const range = computePresetRange(preset.value)
} onChange({ ...range, preset: preset.value })
content={ }}
<div className={styles.presetList}> />
{DROPDOWN_PRESETS.map((preset) => ( ))}
<FilterPill <FilterPill
key={preset.value} ref={customRef}
label={preset.label} label="Custom"
active={value.preset === preset.value} active={isCustom}
onClick={() => { onClick={() => setOpen((prev) => !prev)}
const range = computePresetRange(preset.value) />
onChange({ ...range, preset: preset.value }) <span className={styles.readout} aria-label="Active time range">
}} {rangeLabel}
/> </span>
))} </ButtonGroup>
</div>
} {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 { Avatar } from './Avatar/Avatar'
export { Badge } from './Badge/Badge' export { Badge } from './Badge/Badge'
export { Button } from './Button/Button' export { Button } from './Button/Button'
export { ButtonGroup } from './ButtonGroup/ButtonGroup'
export { Card } from './Card/Card' export { Card } from './Card/Card'
export { Checkbox } from './Checkbox/Checkbox' export { Checkbox } from './Checkbox/Checkbox'
export { CodeBlock } from './CodeBlock/CodeBlock' export { CodeBlock } from './CodeBlock/CodeBlock'

View File

@@ -20,7 +20,7 @@ interface GlobalFilterContextValue {
const GlobalFilterContext = createContext<GlobalFilterContextValue | null>(null) const GlobalFilterContext = createContext<GlobalFilterContextValue | null>(null)
const DEFAULT_PRESET = 'last-3h' const DEFAULT_PRESET = 'last-1h'
function getDefaultTimeRange(): TimeRange { function getDefaultTimeRange(): TimeRange {
const { start, end } = computePresetRange(DEFAULT_PRESET) const { start, end } = computePresetRange(DEFAULT_PRESET)