Skip to content

Commit f7eb2b7

Browse files
committed
feat(portfolio): show per-category counts on filter buttons
Each filter chip (Featured / Community / Collab / Others / All) now carries a small count badge so visitors can see how many projects live in each bucket before clicking. Empty categories are auto-hidden (All is always kept as a fallback). Counts use tabular-nums so width is stable across 1- and 2-digit values. Includes aria-label per button so screen readers announce the count.
1 parent c0b48c8 commit f7eb2b7

1 file changed

Lines changed: 89 additions & 34 deletions

File tree

src/pages/portfolio/Portfolio.tsx

Lines changed: 89 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,33 @@ const Portfolio = () => {
3434
const collaborativeProjects = useMemo(() => getCollaborativeProjects(), []);
3535
const otherProjects = useMemo(() => getOtherProjects(), []);
3636

37+
// Counts per filter — drives both the badge text and the "hide empty" rule.
38+
const counts = useMemo<Record<string, number>>(() => {
39+
const f = featuredProjects.length;
40+
const c = communityProjects.length;
41+
const x = collaborativeProjects.length;
42+
const o = otherProjects.length;
43+
return {
44+
Featured: f,
45+
Community: c,
46+
Collab: x,
47+
Others: o,
48+
All: f + c + x + o,
49+
};
50+
}, [
51+
featuredProjects,
52+
communityProjects,
53+
collaborativeProjects,
54+
otherProjects,
55+
]);
56+
57+
// Skip categories with zero items so the filter bar never shows dead options.
58+
// "All" is kept even at zero so the bar still renders with a fallback.
59+
const visibleFilters = useMemo(
60+
() => FILTERS.filter((f) => f === "All" || (counts[f] ?? 0) > 0),
61+
[counts],
62+
);
63+
3764
const filteredProjects = useMemo(() => {
3865
const featured = featuredProjects.map((p) => ({
3966
...p,
@@ -84,40 +111,68 @@ const Portfolio = () => {
84111
}}
85112
variants={fadeInUp}
86113
>
87-
{FILTERS.map((filter, idx) => (
88-
<motion.button
89-
key={filter}
90-
onClick={() => handleFilterChange(filter)}
91-
className={activeFilter === filter ? "btn-primary" : ""}
92-
style={
93-
activeFilter === filter
94-
? {}
95-
: {
96-
padding: "8px 20px",
97-
borderRadius: 12,
98-
fontSize: 14,
99-
fontFamily: MONO_FONT,
100-
fontWeight: 500,
101-
cursor: "pointer",
102-
border: "1px solid rgba(255, 255, 255, 0.06)",
103-
color: "#a5a5c0",
104-
background: "rgba(255, 255, 255, 0.03)",
105-
backdropFilter: "blur(8px)",
106-
}
107-
}
108-
initial={{ opacity: 0, y: 15 }}
109-
animate={{ opacity: 1, y: 0 }}
110-
transition={{
111-
duration: 0.7,
112-
ease: [0.4, 0, 0.2, 1],
113-
delay: 0.1 + idx * 0.08,
114-
}}
115-
whileHover={{ scale: 1.05 }}
116-
whileTap={{ scale: 0.97 }}
117-
>
118-
{filter}
119-
</motion.button>
120-
))}
114+
{visibleFilters.map((filter, idx) => {
115+
const isActive = activeFilter === filter;
116+
const count = counts[filter] ?? 0;
117+
return (
118+
<motion.button
119+
key={filter}
120+
onClick={() => handleFilterChange(filter)}
121+
className={isActive ? "btn-primary" : ""}
122+
style={
123+
isActive
124+
? {}
125+
: {
126+
padding: "8px 20px",
127+
borderRadius: 12,
128+
fontSize: 14,
129+
fontFamily: MONO_FONT,
130+
fontWeight: 500,
131+
cursor: "pointer",
132+
border:
133+
"1px solid rgba(255, 255, 255, 0.06)",
134+
color: "#a5a5c0",
135+
background: "rgba(255, 255, 255, 0.03)",
136+
backdropFilter: "blur(8px)",
137+
display: "inline-flex",
138+
alignItems: "center",
139+
gap: 8,
140+
}
141+
}
142+
initial={{ opacity: 0, y: 15 }}
143+
animate={{ opacity: 1, y: 0 }}
144+
transition={{
145+
duration: 0.7,
146+
ease: [0.4, 0, 0.2, 1],
147+
delay: 0.1 + idx * 0.08,
148+
}}
149+
whileHover={{ scale: 1.05 }}
150+
whileTap={{ scale: 0.97 }}
151+
aria-label={`${filter} (${count} project${count === 1 ? "" : "s"})`}
152+
>
153+
<span>{filter}</span>
154+
<span
155+
aria-hidden="true"
156+
style={{
157+
fontFamily: MONO_FONT,
158+
fontSize: 11,
159+
fontWeight: 600,
160+
opacity: 0.65,
161+
padding: "1px 6px",
162+
borderRadius: 6,
163+
background: isActive
164+
? "rgba(0, 0, 0, 0.18)"
165+
: "rgba(255, 255, 255, 0.06)",
166+
// Use tabular digits so 1-digit and 2-digit counts
167+
// don't shift the button width during filter swaps.
168+
fontVariantNumeric: "tabular-nums",
169+
}}
170+
>
171+
{count}
172+
</span>
173+
</motion.button>
174+
);
175+
})}
121176
</motion.div>
122177

123178
{/* Vertical timeline */}

0 commit comments

Comments
 (0)