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