@umituz/web-design-system 2.3.0 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/presentation/molecules/ActiveFilterTags.tsx +83 -0
- package/src/presentation/molecules/Breadcrumb.tsx +53 -0
- package/src/presentation/molecules/CodeBlock.tsx +144 -0
- package/src/presentation/molecules/index.ts +10 -0
- package/src/presentation/organisms/FilterBar.tsx +186 -0
- package/src/presentation/organisms/FilterSidebar.tsx +172 -0
- package/src/presentation/organisms/index.ts +7 -0
package/package.json
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ActiveFilterTags Component (Molecule)
|
|
3
|
+
* @description Display active filters with remove buttons
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { BaseProps } from '../../domain/types';
|
|
7
|
+
|
|
8
|
+
export interface ActiveFilterTagsProps extends BaseProps {
|
|
9
|
+
selectedCategory: string | null;
|
|
10
|
+
categories: Array<{ id: string; name: string }>;
|
|
11
|
+
selectedTags: string[];
|
|
12
|
+
onCategoryRemove: () => void;
|
|
13
|
+
onTagRemove: (tag: string) => void;
|
|
14
|
+
onClearAll: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const ActiveFilterTags = ({
|
|
18
|
+
selectedCategory,
|
|
19
|
+
categories,
|
|
20
|
+
selectedTags,
|
|
21
|
+
onCategoryRemove,
|
|
22
|
+
onTagRemove,
|
|
23
|
+
onClearAll,
|
|
24
|
+
className,
|
|
25
|
+
}: ActiveFilterTagsProps) => {
|
|
26
|
+
const categoryName = selectedCategory
|
|
27
|
+
? categories.find(c => c.id === selectedCategory)?.name
|
|
28
|
+
: null;
|
|
29
|
+
|
|
30
|
+
if (!selectedCategory && selectedTags.length === 0) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className={`hidden lg:flex flex-wrap items-center gap-2 mb-4 md:mb-6 ${className || ''}`}>
|
|
36
|
+
<span className="text-sm text-text-secondary">Active filters:</span>
|
|
37
|
+
|
|
38
|
+
{/* Category Tag */}
|
|
39
|
+
{categoryName && (
|
|
40
|
+
<span className="inline-flex items-center gap-1 px-3 py-1.5 bg-primary-light text-text-primary rounded-full text-xs font-medium">
|
|
41
|
+
{categoryName}
|
|
42
|
+
<button
|
|
43
|
+
onClick={onCategoryRemove}
|
|
44
|
+
className="ml-1 hover:opacity-70 transition-opacity"
|
|
45
|
+
aria-label={`Remove ${categoryName} filter`}
|
|
46
|
+
>
|
|
47
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
48
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
49
|
+
</svg>
|
|
50
|
+
</button>
|
|
51
|
+
</span>
|
|
52
|
+
)}
|
|
53
|
+
|
|
54
|
+
{/* Tag Buttons */}
|
|
55
|
+
{selectedTags.map((tag) => (
|
|
56
|
+
<span
|
|
57
|
+
key={tag}
|
|
58
|
+
className="inline-flex items-center gap-1 px-3 py-1.5 bg-bg-secondary text-text-primary rounded-full text-xs font-medium border border-border"
|
|
59
|
+
>
|
|
60
|
+
{tag}
|
|
61
|
+
<button
|
|
62
|
+
onClick={() => onTagRemove(tag)}
|
|
63
|
+
className="ml-1 hover:opacity-70 transition-opacity"
|
|
64
|
+
aria-label={`Remove ${tag} filter`}
|
|
65
|
+
>
|
|
66
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
67
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
68
|
+
</svg>
|
|
69
|
+
</button>
|
|
70
|
+
</span>
|
|
71
|
+
))}
|
|
72
|
+
|
|
73
|
+
{/* Clear All Button */}
|
|
74
|
+
<button
|
|
75
|
+
onClick={onClearAll}
|
|
76
|
+
className="px-3 py-1.5 text-primary-light hover:underline text-xs font-medium"
|
|
77
|
+
type="button"
|
|
78
|
+
>
|
|
79
|
+
Clear all
|
|
80
|
+
</button>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Breadcrumb Component (Molecule)
|
|
3
|
+
* @description Navigation breadcrumb with home icon
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Link } from 'react-router-dom';
|
|
7
|
+
import type { BaseProps } from '../../domain/types';
|
|
8
|
+
|
|
9
|
+
export interface BreadcrumbItem {
|
|
10
|
+
label: string;
|
|
11
|
+
href?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface BreadcrumbProps extends BaseProps {
|
|
15
|
+
items: BreadcrumbItem[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const Breadcrumb = ({ items, className }: BreadcrumbProps) => {
|
|
19
|
+
return (
|
|
20
|
+
<nav className={`flex items-center gap-2 text-sm mb-4 md:mb-6 ${className || ''}`} aria-label="Breadcrumb">
|
|
21
|
+
{/* Home */}
|
|
22
|
+
<Link
|
|
23
|
+
to="/"
|
|
24
|
+
className="flex items-center gap-1 text-text-secondary hover:text-primary-light transition-colors"
|
|
25
|
+
aria-label="Home"
|
|
26
|
+
>
|
|
27
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
28
|
+
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
|
29
|
+
<polyline points="9 22 9 12 15 12 15 22" />
|
|
30
|
+
</svg>
|
|
31
|
+
</Link>
|
|
32
|
+
|
|
33
|
+
{/* Breadcrumb items */}
|
|
34
|
+
{items.map((item, index) => (
|
|
35
|
+
<div key={index} className="flex items-center gap-2">
|
|
36
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-text-secondary">
|
|
37
|
+
<polyline points="9 18 15 12 9 6" />
|
|
38
|
+
</svg>
|
|
39
|
+
{item.href ? (
|
|
40
|
+
<Link
|
|
41
|
+
to={item.href}
|
|
42
|
+
className="text-text-secondary hover:text-primary-light transition-colors"
|
|
43
|
+
>
|
|
44
|
+
{item.label}
|
|
45
|
+
</Link>
|
|
46
|
+
) : (
|
|
47
|
+
<span className="text-text-primary font-medium">{item.label}</span>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
))}
|
|
51
|
+
</nav>
|
|
52
|
+
);
|
|
53
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodeBlock Component (Molecule)
|
|
3
|
+
* @description Syntax-highlighted code block with copy button and expand/collapse
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useEffect } from 'react';
|
|
7
|
+
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
8
|
+
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
|
9
|
+
import type { BaseProps } from '../../domain/types';
|
|
10
|
+
|
|
11
|
+
export interface CodeBlockProps extends BaseProps {
|
|
12
|
+
children: string;
|
|
13
|
+
language?: string;
|
|
14
|
+
filename?: string;
|
|
15
|
+
showLineNumbers?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const CodeBlock = ({
|
|
19
|
+
children,
|
|
20
|
+
language = 'javascript',
|
|
21
|
+
filename,
|
|
22
|
+
showLineNumbers = false,
|
|
23
|
+
className,
|
|
24
|
+
}: CodeBlockProps) => {
|
|
25
|
+
const [copied, setCopied] = useState(false);
|
|
26
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
27
|
+
|
|
28
|
+
// Cleanup copy timeout
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (copied) {
|
|
31
|
+
const timeout = setTimeout(() => setCopied(false), 2000);
|
|
32
|
+
return () => clearTimeout(timeout);
|
|
33
|
+
}
|
|
34
|
+
}, [copied]);
|
|
35
|
+
|
|
36
|
+
const handleCopy = async () => {
|
|
37
|
+
try {
|
|
38
|
+
await navigator.clipboard.writeText(children);
|
|
39
|
+
setCopied(true);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error('Failed to copy code:', error);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Auto-detect language if not provided
|
|
46
|
+
const detectedLanguage = language || 'javascript';
|
|
47
|
+
|
|
48
|
+
// Check if code is long enough for collapse
|
|
49
|
+
const lineCount = children.split('\n').length;
|
|
50
|
+
const isLongCode = lineCount > 10;
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className={`my-6 rounded-lg border border-border overflow-hidden bg-bg-card transition-theme ${className || ''}`}>
|
|
54
|
+
{/* Header */}
|
|
55
|
+
<div className="flex items-center justify-between px-4 py-2 bg-bg-tertiary border-b border-border">
|
|
56
|
+
<div className="flex items-center gap-2">
|
|
57
|
+
{filename && (
|
|
58
|
+
<span className="text-xs font-medium text-text-secondary">{filename}</span>
|
|
59
|
+
)}
|
|
60
|
+
<span className="text-xs px-2 py-1 bg-bg-primary text-text-primary rounded-md font-medium">
|
|
61
|
+
{detectedLanguage}
|
|
62
|
+
</span>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div className="flex items-center gap-2">
|
|
66
|
+
{isLongCode && (
|
|
67
|
+
<button
|
|
68
|
+
onClick={() => setIsExpanded(!isExpanded)}
|
|
69
|
+
className="p-1 text-text-secondary hover:text-text-primary rounded transition-colors"
|
|
70
|
+
title={isExpanded ? 'Collapse' : 'Expand'}
|
|
71
|
+
type="button"
|
|
72
|
+
aria-label={isExpanded ? 'Collapse code' : 'Expand code'}
|
|
73
|
+
>
|
|
74
|
+
{isExpanded ? (
|
|
75
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
76
|
+
<polyline points="18 15 12 9 6 15" />
|
|
77
|
+
</svg>
|
|
78
|
+
) : (
|
|
79
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
80
|
+
<polyline points="6 9 12 15 18 9" />
|
|
81
|
+
</svg>
|
|
82
|
+
)}
|
|
83
|
+
</button>
|
|
84
|
+
)}
|
|
85
|
+
|
|
86
|
+
<button
|
|
87
|
+
onClick={handleCopy}
|
|
88
|
+
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
|
|
89
|
+
copied
|
|
90
|
+
? 'bg-green-500 text-white'
|
|
91
|
+
: 'bg-bg-secondary text-text-primary hover:bg-bg-tertiary border border-border'
|
|
92
|
+
}`}
|
|
93
|
+
title={copied ? 'Copied!' : 'Copy code'}
|
|
94
|
+
type="button"
|
|
95
|
+
aria-label={copied ? 'Code copied' : 'Copy code'}
|
|
96
|
+
>
|
|
97
|
+
{copied ? (
|
|
98
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
99
|
+
<polyline points="20 6 9 17 4 12" />
|
|
100
|
+
</svg>
|
|
101
|
+
) : (
|
|
102
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
103
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
104
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
105
|
+
</svg>
|
|
106
|
+
)}
|
|
107
|
+
<span className="hidden sm:inline">{copied ? 'Copied!' : 'Copy'}</span>
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{/* Code */}
|
|
113
|
+
<div className={isLongCode && !isExpanded ? 'max-h-80 overflow-hidden' : ''}>
|
|
114
|
+
<SyntaxHighlighter
|
|
115
|
+
language={detectedLanguage}
|
|
116
|
+
style={vscDarkPlus}
|
|
117
|
+
showLineNumbers={showLineNumbers}
|
|
118
|
+
customStyle={{
|
|
119
|
+
margin: 0,
|
|
120
|
+
borderRadius: 0,
|
|
121
|
+
fontSize: '14px',
|
|
122
|
+
background: 'transparent',
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
{children}
|
|
126
|
+
</SyntaxHighlighter>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{/* Expand hint for collapsed code */}
|
|
130
|
+
{isLongCode && !isExpanded && (
|
|
131
|
+
<div className="px-4 py-2 bg-bg-secondary text-center border-t border-border">
|
|
132
|
+
<button
|
|
133
|
+
onClick={() => setIsExpanded(true)}
|
|
134
|
+
className="text-sm text-primary-light hover:underline font-medium"
|
|
135
|
+
type="button"
|
|
136
|
+
aria-label={`Show all ${lineCount} lines`}
|
|
137
|
+
>
|
|
138
|
+
Show all {lineCount} lines
|
|
139
|
+
</button>
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
};
|
|
@@ -37,3 +37,13 @@ export { ScrollArea, ScrollBar } from './ScrollArea';
|
|
|
37
37
|
|
|
38
38
|
export { ListItem } from './ListItem';
|
|
39
39
|
export type { ListItemProps } from './ListItem';
|
|
40
|
+
|
|
41
|
+
// NEW: Filter & Navigation Components
|
|
42
|
+
export { ActiveFilterTags } from './ActiveFilterTags';
|
|
43
|
+
export type { ActiveFilterTagsProps } from './ActiveFilterTags';
|
|
44
|
+
|
|
45
|
+
export { Breadcrumb } from './Breadcrumb';
|
|
46
|
+
export type { BreadcrumbProps, BreadcrumbItem } from './Breadcrumb';
|
|
47
|
+
|
|
48
|
+
export { CodeBlock } from './CodeBlock';
|
|
49
|
+
export type { CodeBlockProps } from './CodeBlock';
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilterBar Component (Organism)
|
|
3
|
+
* @description Mobile filter bar with search, categories, tags, and sort
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState } from 'react';
|
|
7
|
+
import type { BaseProps } from '../../domain/types';
|
|
8
|
+
|
|
9
|
+
export interface Category {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
count: number;
|
|
13
|
+
icon: React.ComponentType<{ className?: string; size?: number }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SortOption {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FilterBarProps extends BaseProps {
|
|
22
|
+
searchQuery: string;
|
|
23
|
+
setSearchQuery: (query: string) => void;
|
|
24
|
+
selectedCategory: string | null;
|
|
25
|
+
onCategoryChange: (category: string) => void;
|
|
26
|
+
categories: Category[];
|
|
27
|
+
popularTags: string[];
|
|
28
|
+
selectedTags: string[];
|
|
29
|
+
onTagToggle: (tag: string) => void;
|
|
30
|
+
sortBy: string;
|
|
31
|
+
onSortChange: (sort: string) => void;
|
|
32
|
+
sortOptions: readonly SortOption[] | SortOption[];
|
|
33
|
+
hasActiveFilters: boolean;
|
|
34
|
+
onClearFilters: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const FilterBar = ({
|
|
38
|
+
searchQuery,
|
|
39
|
+
setSearchQuery,
|
|
40
|
+
selectedCategory,
|
|
41
|
+
onCategoryChange,
|
|
42
|
+
categories,
|
|
43
|
+
popularTags,
|
|
44
|
+
selectedTags,
|
|
45
|
+
onTagToggle,
|
|
46
|
+
sortBy,
|
|
47
|
+
onSortChange,
|
|
48
|
+
sortOptions,
|
|
49
|
+
hasActiveFilters,
|
|
50
|
+
onClearFilters,
|
|
51
|
+
className,
|
|
52
|
+
}: FilterBarProps) => {
|
|
53
|
+
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className={`lg:hidden mb-4 space-y-3 ${className || ''}`}>
|
|
57
|
+
{/* Search */}
|
|
58
|
+
<div className="relative">
|
|
59
|
+
<svg
|
|
60
|
+
className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary"
|
|
61
|
+
width="18"
|
|
62
|
+
height="18"
|
|
63
|
+
viewBox="0 0 24 24"
|
|
64
|
+
fill="none"
|
|
65
|
+
stroke="currentColor"
|
|
66
|
+
strokeWidth="2"
|
|
67
|
+
strokeLinecap="round"
|
|
68
|
+
strokeLinejoin="round"
|
|
69
|
+
>
|
|
70
|
+
<circle cx="11" cy="11" r="8" />
|
|
71
|
+
<path d="m21 21-4.35-4.35" />
|
|
72
|
+
</svg>
|
|
73
|
+
<input
|
|
74
|
+
type="text"
|
|
75
|
+
placeholder="Search..."
|
|
76
|
+
value={searchQuery}
|
|
77
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
78
|
+
className="w-full pl-10 pr-4 py-2.5 bg-bg-secondary text-text-primary rounded-lg border border-border focus:border-primary-light focus:outline-none placeholder-text-secondary/50 transition-theme text-sm"
|
|
79
|
+
aria-label="Search"
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{/* Filter Toggle + Sort + Clear Filters */}
|
|
84
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
85
|
+
<button
|
|
86
|
+
onClick={() => setIsFilterOpen(!isFilterOpen)}
|
|
87
|
+
className="flex items-center gap-2 px-4 py-2.5 bg-bg-secondary text-text-primary rounded-lg border border-border hover:border-primary-light transition-all text-sm font-medium transition-theme"
|
|
88
|
+
type="button"
|
|
89
|
+
aria-expanded={isFilterOpen}
|
|
90
|
+
aria-label="Toggle filters"
|
|
91
|
+
>
|
|
92
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
93
|
+
<path d="M12 20v-6M6 20V10M18 20V4" />
|
|
94
|
+
</svg>
|
|
95
|
+
Filters
|
|
96
|
+
{(selectedCategory || selectedTags.length > 0) && (
|
|
97
|
+
<span className="ml-1 px-2 py-0.5 bg-primary-light text-text-primary text-xs rounded-full">
|
|
98
|
+
{[selectedCategory, ...selectedTags].filter(Boolean).length}
|
|
99
|
+
</span>
|
|
100
|
+
)}
|
|
101
|
+
</button>
|
|
102
|
+
|
|
103
|
+
{/* Sort Dropdown */}
|
|
104
|
+
<select
|
|
105
|
+
value={sortBy}
|
|
106
|
+
onChange={(e) => onSortChange(e.target.value)}
|
|
107
|
+
className="px-3 py-2.5 bg-bg-secondary text-text-primary rounded-lg border border-border focus:border-primary-light focus:outline-none transition-theme text-sm"
|
|
108
|
+
aria-label="Sort by"
|
|
109
|
+
>
|
|
110
|
+
{sortOptions.map((option) => (
|
|
111
|
+
<option key={option.id} value={option.id}>
|
|
112
|
+
{option.name}
|
|
113
|
+
</option>
|
|
114
|
+
))}
|
|
115
|
+
</select>
|
|
116
|
+
|
|
117
|
+
{/* Clear Filters */}
|
|
118
|
+
{hasActiveFilters && (
|
|
119
|
+
<button
|
|
120
|
+
onClick={onClearFilters}
|
|
121
|
+
className="px-3 py-2.5 text-text-secondary hover:text-text-primary text-sm transition-colors"
|
|
122
|
+
type="button"
|
|
123
|
+
>
|
|
124
|
+
Clear All
|
|
125
|
+
</button>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{/* Mobile Filter Drawer */}
|
|
130
|
+
{isFilterOpen && (
|
|
131
|
+
<div className="bg-bg-card rounded-xl p-4 border border-border space-y-4 transition-theme">
|
|
132
|
+
{/* Categories */}
|
|
133
|
+
<div>
|
|
134
|
+
<h4 className="text-sm font-semibold text-text-primary mb-3">Categories</h4>
|
|
135
|
+
<div className="grid grid-cols-2 gap-2">
|
|
136
|
+
{categories.map((category) => {
|
|
137
|
+
const Icon = category.icon;
|
|
138
|
+
const isActive = selectedCategory === category.id;
|
|
139
|
+
return (
|
|
140
|
+
<button
|
|
141
|
+
key={category.id}
|
|
142
|
+
onClick={() => {
|
|
143
|
+
onCategoryChange(category.id);
|
|
144
|
+
}}
|
|
145
|
+
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-xs transition-all transition-theme ${
|
|
146
|
+
isActive
|
|
147
|
+
? 'bg-primary-light text-text-primary font-medium'
|
|
148
|
+
: 'bg-bg-secondary text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
|
149
|
+
}`}
|
|
150
|
+
type="button"
|
|
151
|
+
aria-pressed={isActive}
|
|
152
|
+
>
|
|
153
|
+
<Icon size={14} />
|
|
154
|
+
<span>{category.name}</span>
|
|
155
|
+
</button>
|
|
156
|
+
);
|
|
157
|
+
})}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
{/* Popular Tags */}
|
|
162
|
+
<div>
|
|
163
|
+
<h4 className="text-sm font-semibold text-text-primary mb-3">Popular Tags</h4>
|
|
164
|
+
<div className="flex flex-wrap gap-2">
|
|
165
|
+
{popularTags.map((tag) => (
|
|
166
|
+
<button
|
|
167
|
+
key={tag}
|
|
168
|
+
onClick={() => onTagToggle(tag)}
|
|
169
|
+
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-all transition-theme ${
|
|
170
|
+
selectedTags.includes(tag)
|
|
171
|
+
? 'bg-primary-light text-text-primary'
|
|
172
|
+
: 'bg-bg-secondary text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
|
173
|
+
}`}
|
|
174
|
+
type="button"
|
|
175
|
+
aria-pressed={selectedTags.includes(tag)}
|
|
176
|
+
>
|
|
177
|
+
{tag}
|
|
178
|
+
</button>
|
|
179
|
+
))}
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilterSidebar Component (Organism)
|
|
3
|
+
* @description Desktop sidebar with search, categories, tags, and sort options
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { BaseProps } from '../../domain/types';
|
|
7
|
+
import type { ReactNode } from 'react';
|
|
8
|
+
|
|
9
|
+
export interface Category {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
count: number;
|
|
13
|
+
icon: React.ComponentType<{ className?: string; size?: number }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SortOption {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FilterSidebarProps extends BaseProps {
|
|
22
|
+
searchQuery: string;
|
|
23
|
+
setSearchQuery: (query: string) => void;
|
|
24
|
+
selectedCategory: string | null;
|
|
25
|
+
onCategoryChange: (category: string) => void;
|
|
26
|
+
categories: Category[];
|
|
27
|
+
popularTags: string[];
|
|
28
|
+
selectedTags: string[];
|
|
29
|
+
onTagToggle: (tag: string) => void;
|
|
30
|
+
sortBy: string;
|
|
31
|
+
onSortChange: (sort: string) => void;
|
|
32
|
+
sortOptions: readonly SortOption[] | SortOption[];
|
|
33
|
+
hasActiveFilters: boolean;
|
|
34
|
+
onClearFilters: () => void;
|
|
35
|
+
children?: React.ReactNode;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const FilterSidebar = ({
|
|
39
|
+
searchQuery,
|
|
40
|
+
setSearchQuery,
|
|
41
|
+
selectedCategory,
|
|
42
|
+
onCategoryChange,
|
|
43
|
+
categories,
|
|
44
|
+
popularTags,
|
|
45
|
+
selectedTags,
|
|
46
|
+
onTagToggle,
|
|
47
|
+
sortBy,
|
|
48
|
+
onSortChange,
|
|
49
|
+
sortOptions,
|
|
50
|
+
hasActiveFilters,
|
|
51
|
+
onClearFilters,
|
|
52
|
+
children,
|
|
53
|
+
className,
|
|
54
|
+
}: FilterSidebarProps) => {
|
|
55
|
+
return (
|
|
56
|
+
<aside className={`hidden lg:block w-64 flex-shrink-0 ${className || ''}`}>
|
|
57
|
+
<div className="sticky top-24 space-y-6">
|
|
58
|
+
{/* Search */}
|
|
59
|
+
<div className="relative">
|
|
60
|
+
<svg
|
|
61
|
+
className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary"
|
|
62
|
+
width="18"
|
|
63
|
+
height="18"
|
|
64
|
+
viewBox="0 0 24 24"
|
|
65
|
+
fill="none"
|
|
66
|
+
stroke="currentColor"
|
|
67
|
+
strokeWidth="2"
|
|
68
|
+
strokeLinecap="round"
|
|
69
|
+
strokeLinejoin="round"
|
|
70
|
+
>
|
|
71
|
+
<circle cx="11" cy="11" r="8" />
|
|
72
|
+
<path d="m21 21-4.35-4.35" />
|
|
73
|
+
</svg>
|
|
74
|
+
<input
|
|
75
|
+
type="text"
|
|
76
|
+
placeholder="Search..."
|
|
77
|
+
value={searchQuery}
|
|
78
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
79
|
+
className="w-full pl-10 pr-4 py-2.5 bg-bg-secondary text-text-primary rounded-lg border border-border focus:border-primary-light focus:outline-none placeholder-text-secondary/50 transition-theme text-sm"
|
|
80
|
+
aria-label="Search"
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{/* Categories */}
|
|
85
|
+
<div>
|
|
86
|
+
<h3 className="text-sm font-semibold text-text-primary mb-3">Categories</h3>
|
|
87
|
+
<div className="space-y-1">
|
|
88
|
+
{categories.map((category) => {
|
|
89
|
+
const Icon = category.icon;
|
|
90
|
+
const isActive = selectedCategory === category.id;
|
|
91
|
+
return (
|
|
92
|
+
<button
|
|
93
|
+
key={category.id}
|
|
94
|
+
onClick={() => onCategoryChange(category.id)}
|
|
95
|
+
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all transition-theme ${
|
|
96
|
+
isActive
|
|
97
|
+
? 'bg-primary-light text-text-primary font-medium'
|
|
98
|
+
: 'text-text-secondary hover:bg-bg-secondary hover:text-text-primary'
|
|
99
|
+
}`}
|
|
100
|
+
type="button"
|
|
101
|
+
aria-pressed={isActive}
|
|
102
|
+
>
|
|
103
|
+
<Icon size={16} />
|
|
104
|
+
<span className="flex-1 text-left">{category.name}</span>
|
|
105
|
+
<span className={`text-xs ${isActive ? 'text-text-primary' : 'text-text-secondary'}`}>
|
|
106
|
+
{category.count}
|
|
107
|
+
</span>
|
|
108
|
+
</button>
|
|
109
|
+
);
|
|
110
|
+
})}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{/* Popular Tags */}
|
|
115
|
+
<div>
|
|
116
|
+
<h3 className="text-sm font-semibold text-text-primary mb-3">Popular Tags</h3>
|
|
117
|
+
<div className="flex flex-wrap gap-2">
|
|
118
|
+
{popularTags.map((tag) => (
|
|
119
|
+
<button
|
|
120
|
+
key={tag}
|
|
121
|
+
onClick={() => onTagToggle(tag)}
|
|
122
|
+
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-all transition-theme ${
|
|
123
|
+
selectedTags.includes(tag)
|
|
124
|
+
? 'bg-primary-light text-text-primary'
|
|
125
|
+
: 'bg-bg-secondary text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
|
126
|
+
}`}
|
|
127
|
+
type="button"
|
|
128
|
+
aria-pressed={selectedTags.includes(tag)}
|
|
129
|
+
>
|
|
130
|
+
{tag}
|
|
131
|
+
</button>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
{/* Sort */}
|
|
137
|
+
<div>
|
|
138
|
+
<h3 className="text-sm font-semibold text-text-primary mb-3">Sort By</h3>
|
|
139
|
+
<select
|
|
140
|
+
value={sortBy}
|
|
141
|
+
onChange={(e) => onSortChange(e.target.value)}
|
|
142
|
+
className="w-full px-3 py-2.5 bg-bg-secondary text-text-primary rounded-lg border border-border focus:border-primary-light focus:outline-none transition-theme text-sm"
|
|
143
|
+
aria-label="Sort by"
|
|
144
|
+
>
|
|
145
|
+
{sortOptions.map((option) => (
|
|
146
|
+
<option key={option.id} value={option.id}>
|
|
147
|
+
{option.name}
|
|
148
|
+
</option>
|
|
149
|
+
))}
|
|
150
|
+
</select>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
{/* Additional Content */}
|
|
154
|
+
{children}
|
|
155
|
+
|
|
156
|
+
{/* Clear Filters */}
|
|
157
|
+
{hasActiveFilters && (
|
|
158
|
+
<button
|
|
159
|
+
onClick={onClearFilters}
|
|
160
|
+
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-bg-secondary text-text-secondary rounded-lg border border-border hover:border-primary-light hover:text-text-primary transition-all text-sm font-medium transition-theme"
|
|
161
|
+
type="button"
|
|
162
|
+
>
|
|
163
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
164
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
165
|
+
</svg>
|
|
166
|
+
Clear All Filters
|
|
167
|
+
</button>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
</aside>
|
|
171
|
+
);
|
|
172
|
+
};
|
|
@@ -142,3 +142,10 @@ export type { NewsletterSignupProps } from './NewsletterSignup';
|
|
|
142
142
|
|
|
143
143
|
export { Comments } from './Comments';
|
|
144
144
|
export type { CommentsProps, GiscusConfig } from './Comments';
|
|
145
|
+
|
|
146
|
+
// NEW: Filter Components
|
|
147
|
+
export { FilterBar } from './FilterBar';
|
|
148
|
+
export type { FilterBarProps, Category, SortOption } from './FilterBar';
|
|
149
|
+
|
|
150
|
+
export { FilterSidebar } from './FilterSidebar';
|
|
151
|
+
export type { FilterSidebarProps } from './FilterSidebar';
|