Skip to content

Commit 16053aa

Browse files
Merge branch 'main' into feature/sidebar-ide-style
2 parents b197f1e + a584e1f commit 16053aa

2 files changed

Lines changed: 71 additions & 9 deletions

File tree

imagelab-frontend/src/components/Sidebar/CategorySection.tsx

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from "react";
1+
import { useState, useMemo } from "react";
22
import * as Blockly from "blockly";
33
import {
44
ChevronRight,
@@ -35,6 +35,7 @@ interface CategorySectionProps {
3535
previews: Map<string, BlockPreview>;
3636
disabledTypes: Set<string>;
3737
defaultOpen?: boolean;
38+
searchQuery?: string;
3839
}
3940

4041
export default function CategorySection({
@@ -43,28 +44,49 @@ export default function CategorySection({
4344
previews,
4445
disabledTypes,
4546
defaultOpen,
47+
searchQuery = "",
4648
}: CategorySectionProps) {
4749
const [isOpen, setIsOpen] = useState(defaultOpen ?? false);
4850
const Icon = iconMap[category.icon];
4951

52+
const isSearching = searchQuery.trim().length > 0;
53+
54+
const filteredBlocks = useMemo(() => {
55+
if (!isSearching) return category.blocks;
56+
const lowerQuery = searchQuery.toLowerCase();
57+
return category.blocks.filter((b) => b.label.toLowerCase().includes(lowerQuery));
58+
}, [isSearching, searchQuery, category.blocks]);
59+
60+
const effectiveOpen = isSearching ? filteredBlocks.length > 0 : isOpen;
61+
62+
if (isSearching && filteredBlocks.length === 0) return null;
63+
5064
return (
5165
<div className="border-b border-gray-200">
5266
<button
53-
onClick={() => setIsOpen(!isOpen)}
54-
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-gray-50 transition-colors"
67+
type="button"
68+
onClick={() => {
69+
if (!isSearching) setIsOpen((prev) => !prev);
70+
}}
71+
aria-expanded={effectiveOpen}
72+
className={`w-full flex items-center gap-2 px-3 py-2 hover:bg-gray-50 transition-colors ${isSearching ? "cursor-default" : ""}`}
5573
>
56-
{isOpen ? (
57-
<ChevronDown size={14} className="text-gray-400" />
74+
{effectiveOpen ? (
75+
<ChevronDown size={14} className={isSearching ? "text-gray-200" : "text-gray-400"} />
5876
) : (
59-
<ChevronRight size={14} className="text-gray-400" />
77+
<ChevronRight size={14} className={isSearching ? "text-gray-200" : "text-gray-400"} />
6078
)}
6179
{Icon && <Icon size={16} color={category.colour} />}
6280
<span className="text-sm font-medium text-gray-700">{category.name}</span>
63-
<span className="ml-auto text-xs text-gray-400 font-normal">{category.blocks.length}</span>
81+
<span className="ml-auto text-xs text-gray-400 font-normal">
82+
{isSearching
83+
? `${filteredBlocks.length}/${category.blocks.length}`
84+
: category.blocks.length}
85+
</span>
6486
</button>
65-
{isOpen && (
87+
{effectiveOpen && (
6688
<div className="pb-1 pl-2">
67-
{category.blocks.map((block) => (
89+
{filteredBlocks.map((block) => (
6890
<BlockItem
6991
key={block.type}
7092
type={block.type}

imagelab-frontend/src/components/Sidebar/Sidebar.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { categories } from "../../blocks/categories";
44
import { useBlockPreviews } from "../../hooks/useBlockPreviews";
55
import { SINGLETON_BLOCK_TYPES } from "../../utils/blockLimits";
66
import CategorySection from "./CategorySection";
7+
import { Search, X } from "lucide-react";
78

89
interface SidebarProps {
910
workspace: Blockly.WorkspaceSvg | null;
@@ -20,6 +21,7 @@ export default function Sidebar({
2021
}: SidebarProps) {
2122
const previews = useBlockPreviews();
2223
const [tick, setTick] = useState(0);
24+
const [query, setQuery] = useState("");
2325

2426
useEffect(() => {
2527
if (!workspace) return;
@@ -56,6 +58,44 @@ export default function Sidebar({
5658
>
5759
<div className="px-3 py-2 border-b border-gray-200 min-w-[200px]">
5860
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Blocks</h2>
61+
<div className="relative">
62+
<Search
63+
size={12}
64+
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
65+
/>
66+
<input
67+
type="text"
68+
aria-label="Search blocks"
69+
value={query}
70+
onChange={(e) => setQuery(e.target.value)}
71+
placeholder="Search blocks..."
72+
className="w-full pl-7 pr-7 py-1.5 text-xs border border-gray-200 rounded-md bg-gray-50 focus:outline-none focus:ring-1 focus:ring-indigo-400 focus:border-indigo-400 placeholder-gray-400"
73+
/>
74+
{query && (
75+
<button
76+
type="button"
77+
title="Clear search"
78+
aria-label="Clear search"
79+
onClick={() => setQuery("")}
80+
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
81+
>
82+
<X size={12} aria-hidden="true" />
83+
</button>
84+
)}
85+
</div>
86+
</div>
87+
<div className="overflow-y-auto flex-1">
88+
{categories.map((category) => (
89+
<CategorySection
90+
key={category.name}
91+
category={category}
92+
workspace={workspace}
93+
previews={previews}
94+
disabledTypes={presentSingletons}
95+
defaultOpen={category.name === "Basic"}
96+
searchQuery={query}
97+
/>
98+
))}
5999
</div>
60100
<div className="flex-1 overflow-y-auto min-w-[200px]">
61101
{categories.map((category) => (

0 commit comments

Comments
 (0)