feat: FilterBar and ShortcutsBar composites
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
58
src/design-system/composites/FilterBar/FilterBar.module.css
Normal file
58
src/design-system/composites/FilterBar/FilterBar.module.css
Normal 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);
|
||||
}
|
||||
119
src/design-system/composites/FilterBar/FilterBar.tsx
Normal file
119
src/design-system/composites/FilterBar/FilterBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
27
src/design-system/composites/ShortcutsBar/ShortcutsBar.tsx
Normal file
27
src/design-system/composites/ShortcutsBar/ShortcutsBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user