feat: FilterBar and ShortcutsBar composites

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 09:42:20 +01:00
parent 42bd896383
commit 8daf21428c
4 changed files with 235 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
.section {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 12px 16px;
box-shadow: var(--shadow-card);
}
.row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.searchWrap {
flex: 1;
max-width: 360px;
min-width: 180px;
}
.sep {
width: 1px;
height: 22px;
background: var(--border);
margin: 0 2px;
flex-shrink: 0;
}
.filterGroup {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: wrap;
}
.activeTags {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
margin-top: 8px;
}
.clearAll {
font-size: 11px;
color: var(--text-muted);
cursor: pointer;
padding: 3px 6px;
background: none;
border: none;
font-family: var(--font-body);
transition: color 0.12s;
}
.clearAll:hover {
color: var(--error);
}

View File

@@ -0,0 +1,119 @@
import { useState, type ChangeEvent } from 'react'
import styles from './FilterBar.module.css'
import { Input } from '../../primitives/Input/Input'
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
import { Tag } from '../../primitives/Tag/Tag'
export interface FilterOption {
label: string
value: string
count?: number
color?: 'success' | 'error' | 'running'
}
export interface ActiveFilter {
label: string
value: string
}
interface FilterBarProps {
filters: FilterOption[]
activeFilters: ActiveFilter[]
onFilterChange: (filters: ActiveFilter[]) => void
searchPlaceholder?: string
searchValue?: string
onSearchChange?: (value: string) => void
className?: string
}
export function FilterBar({
filters,
activeFilters,
onFilterChange,
searchPlaceholder = 'Search...',
searchValue,
onSearchChange,
className,
}: FilterBarProps) {
const [internalSearch, setInternalSearch] = useState('')
const search = searchValue !== undefined ? searchValue : internalSearch
function handleSearchChange(e: ChangeEvent<HTMLInputElement>) {
if (onSearchChange) {
onSearchChange(e.target.value)
} else {
setInternalSearch(e.target.value)
}
}
function toggleFilter(filter: FilterOption) {
const isActive = activeFilters.some((f) => f.value === filter.value)
if (isActive) {
onFilterChange(activeFilters.filter((f) => f.value !== filter.value))
} else {
onFilterChange([...activeFilters, { label: filter.label, value: filter.value }])
}
}
function removeFilter(value: string) {
onFilterChange(activeFilters.filter((f) => f.value !== value))
}
function clearAll() {
onFilterChange([])
}
const hasActiveFilters = activeFilters.length > 0
return (
<div className={`${styles.section} ${className ?? ''}`}>
<div className={styles.row}>
<div className={styles.searchWrap}>
<Input
placeholder={searchPlaceholder}
value={search}
onChange={handleSearchChange}
icon={
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
}
/>
</div>
{filters.length > 0 && <div className={styles.sep} />}
{filters.length > 0 && (
<div className={styles.filterGroup}>
{filters.map((filter) => (
<FilterPill
key={filter.value}
label={filter.label}
count={filter.count}
active={activeFilters.some((f) => f.value === filter.value)}
onClick={() => toggleFilter(filter)}
/>
))}
</div>
)}
</div>
{hasActiveFilters && (
<div className={styles.activeTags}>
{activeFilters.map((filter) => (
<Tag
key={filter.value}
label={filter.label}
onRemove={() => removeFilter(filter.value)}
color="auto"
/>
))}
<button className={styles.clearAll} onClick={clearAll} type="button">
Clear all
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,31 @@
.bar {
position: fixed;
bottom: 12px;
right: 12px;
display: flex;
gap: 10px;
z-index: 50;
animation: fadeIn 0.5s ease-out 0.5s both;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.hint {
display: flex;
align-items: center;
gap: 5px;
font-size: 10px;
color: var(--text-muted);
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: 4px 8px;
box-shadow: var(--shadow-sm);
}
.label {
white-space: nowrap;
}

View File

@@ -0,0 +1,27 @@
import styles from './ShortcutsBar.module.css'
import { KeyboardHint } from '../../primitives/KeyboardHint/KeyboardHint'
interface Shortcut {
keys: string
label: string
}
interface ShortcutsBarProps {
shortcuts: Shortcut[]
className?: string
}
export function ShortcutsBar({ shortcuts, className }: ShortcutsBarProps) {
if (shortcuts.length === 0) return null
return (
<div className={`${styles.bar} ${className ?? ''}`}>
{shortcuts.map((shortcut, index) => (
<div key={index} className={styles.hint}>
<KeyboardHint keys={shortcut.keys} />
<span className={styles.label}>{shortcut.label}</span>
</div>
))}
</div>
)
}