diff --git a/craft/src/panels/left/BlocksPanel.test.tsx b/craft/src/panels/left/BlocksPanel.test.tsx
new file mode 100644
index 0000000..adcab52
--- /dev/null
+++ b/craft/src/panels/left/BlocksPanel.test.tsx
@@ -0,0 +1,119 @@
+import { describe, test, expect, afterEach, beforeEach } from 'vitest';
+import React from 'react';
+import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
+import { BlocksPanel } from './BlocksPanel';
+import { searchBlocks } from './blocks-registry';
+
+let h: EditorHarness | null = null;
+beforeEach(() => { window.localStorage.clear(); });
+afterEach(() => { h?.unmount(); h = null; });
+
+function mountPanel(): EditorHarness {
+ const hh = renderEditorHarness();
+ hh.mountChild();
+ return hh;
+}
+
+const input = (hh: EditorHarness) => hh.container.querySelector('input[aria-label="Search blocks"]') as HTMLInputElement;
+const tileIds = (hh: EditorHarness) => Array.from(hh.container.querySelectorAll('.block-item')).map((e) => e.getAttribute('data-block-id'));
+const headers = (hh: EditorHarness) => Array.from(hh.container.querySelectorAll('.block-category-header'));
+
+function type(hh: EditorHarness, value: string) {
+ hh.act(() => {
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!;
+ setter.call(input(hh), value);
+ input(hh).dispatchEvent(new Event('input', { bubbles: true }));
+ });
+}
+function key(hh: EditorHarness, k: string) {
+ hh.act(() => { input(hh).dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true })); });
+}
+
+describe('BlocksPanel search', () => {
+ test('typing "pric" shows a flat list of the matching blocks, no category headers', () => {
+ h = mountPanel();
+ type(h, 'pric');
+ const expected = searchBlocks('pric').map((b) => b.id);
+ expect(expected[0]).toBe('pricing-table');
+ expect(tileIds(h)).toEqual(expected);
+ expect(tileIds(h).length).toBeLessThan(10);
+ expect(headers(h)).toHaveLength(0);
+ });
+
+ test('Enter inserts the first result into the canvas and selects it', () => {
+ h = mountPanel();
+ type(h, 'pric');
+ key(h, 'Enter');
+ const nodes = JSON.parse(h.getSerialized());
+ const rootKids: string[] = nodes.ROOT.nodes;
+ expect(rootKids).toHaveLength(1);
+ expect(nodes[rootKids[0]].type.resolvedName).toBe('PricingTable');
+ expect(h.query.getEvent('selected').all()).toEqual([rootKids[0]]);
+ });
+
+ test('Enter inserts the FIRST of several results (ranked label-prefix)', () => {
+ h = mountPanel();
+ type(h, 'form');
+ expect(tileIds(h).length).toBeGreaterThan(1);
+ key(h, 'Enter');
+ const nodes = JSON.parse(h.getSerialized());
+ expect(nodes[nodes.ROOT.nodes[0]].type.resolvedName).toBe('FormContainer');
+ });
+
+ test('Enter and Escape do not bubble to React ancestors of the input', () => {
+ let seen: string[] = [];
+ h = renderEditorHarness();
+ h.mountChild(
seen.push(e.key)}>
);
+ type(h, 'pric');
+ key(h, 'Enter');
+ key(h, 'Escape');
+ expect(seen).toEqual([]);
+ });
+
+ test('Enter with no results inserts nothing', () => {
+ h = mountPanel();
+ type(h, 'zzzz-nothing');
+ key(h, 'Enter');
+ expect(JSON.parse(h.getSerialized()).ROOT.nodes).toHaveLength(0);
+ expect(h.container.textContent).toContain('No blocks match');
+ });
+
+ test('Escape and clearing the input restore the categories', () => {
+ h = mountPanel();
+ type(h, 'pric');
+ key(h, 'Escape');
+ expect(input(h).value).toBe('');
+ expect(headers(h).length).toBeGreaterThan(1);
+ type(h, 'pric');
+ type(h, '');
+ expect(headers(h).length).toBeGreaterThan(1);
+ });
+
+ test('filtered tiles are still drag sources (Craft marks them draggable)', () => {
+ h = mountPanel();
+ type(h, 'pric');
+ const tiles = Array.from(h.container.querySelectorAll('.block-item'));
+ expect(tiles.length).toBeGreaterThan(0);
+ for (const t of tiles) expect(t.getAttribute('draggable')).toBe('true');
+ });
+});
+
+describe('BlocksPanel categories', () => {
+ test('all categories are expanded initially', () => {
+ h = mountPanel();
+ expect(headers(h).length).toBeGreaterThan(1);
+ expect(h.container.querySelectorAll('.collapsed')).toHaveLength(0);
+ });
+
+ test('collapsing Forms is remembered across a remount', () => {
+ h = mountPanel();
+ const forms = () => headers(h!).find((e) => e.textContent === 'Forms') as HTMLElement;
+ h.act(() => { forms().click(); });
+ expect(forms().classList.contains('collapsed')).toBe(true);
+ h.unmount();
+ h = mountPanel();
+ expect(forms().classList.contains('collapsed')).toBe(true);
+ const basic = headers(h).find((e) => e.textContent === 'Basic')!;
+ expect(basic.classList.contains('collapsed')).toBe(false);
+ });
+});
diff --git a/craft/src/panels/left/BlocksPanel.tsx b/craft/src/panels/left/BlocksPanel.tsx
index 65c4b20..77b6c96 100644
--- a/craft/src/panels/left/BlocksPanel.tsx
+++ b/craft/src/panels/left/BlocksPanel.tsx
@@ -1,8 +1,9 @@
-import React, { useCallback, useState } from 'react';
+import React, { useCallback, useMemo, useState } from 'react';
import { useEditor } from '@craftjs/core';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useMobileChrome } from '../../state/MobileChromeContext';
-import { BLOCKS, BLOCK_CATEGORIES, BlockDef, insertBlock } from './blocks-registry';
+import { getPref, setPref } from '../../utils/local-prefs';
+import { BLOCKS, BLOCK_CATEGORIES, BlockDef, insertBlock, searchBlocks } from './blocks-registry';
// The business category is empty until its blocks land; hide empty groups.
const categories = BLOCK_CATEGORIES
@@ -13,18 +14,34 @@ export const BlocksPanel: React.FC = () => {
const { connectors, actions, query } = useEditor();
const isMobile = useIsMobile();
const { closeSheet } = useMobileChrome();
+ // All categories start open; a collapse the user made is remembered per category.
const [collapsed, setCollapsed] = useState>(() => {
const initial: Record = {};
- categories.forEach((cat, index) => {
- initial[cat.id] = index !== 0; // First category open
- });
+ categories.forEach((cat) => { initial[cat.id] = getPref('blocks:collapsed:' + cat.id, false) === true; });
return initial;
});
+ const [search, setSearch] = useState('');
const toggleCategory = (categoryId: string) => {
- setCollapsed((prev) => ({ ...prev, [categoryId]: !prev[categoryId] }));
+ const next = !collapsed[categoryId];
+ setPref('blocks:collapsed:' + categoryId, next);
+ setCollapsed((prev) => ({ ...prev, [categoryId]: next }));
};
+ // Drag elements are built once per mount (not every render) so typing in the
+ // search box doesn't churn fresh elements / re-register every tile.
+ const dragRefs = useMemo(() => {
+ const map = new Map void>();
+ BLOCKS.forEach((block) => {
+ const element = block.create();
+ map.set(block.id, (el) => { if (el) connectors.create(el, element); });
+ });
+ return map;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const results = useMemo(() => (search.trim() ? searchBlocks(search) : null), [search]);
+
/** Shared by desktop double-click (append to the default canvas, no
* selection change) and mobile tap-to-add (insert after the selection;
* the select/scroll step is deferred below). Returns the new node id. */
@@ -62,9 +79,54 @@ export const BlocksPanel: React.FC = () => {
});
}, [addBlockNode, closeSheet, actions, query]);
+ const renderTile = (block: BlockDef) => (
+ addBlockNode(block, false)}
+ onClick={isMobile ? () => handleTapToAdd(block) : undefined}
+ title={isMobile ? `Tap to add ${block.label}` : `Drag or double-click to add ${block.label}`}
+ >
+
+ {block.label}
+
+ );
+
+ const onSearchKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.stopPropagation();
+ if (results && results.length > 0) {
+ insertBlock(query, actions, results[0].id, { afterSelection: true });
+ if (isMobile) closeSheet();
+ }
+ } else if (e.key === 'Escape') {
+ e.stopPropagation();
+ setSearch('');
+ }
+ };
+
return (
- {categories.map((category) => {
+
+ setSearch(e.target.value)}
+ onKeyDown={onSearchKeyDown}
+ />
+
+ {results ? (
+ results.length > 0 ? (
+
{results.map(renderTile)}
+ ) : (
+
No blocks match "{search.trim()}"
+ )
+ ) : categories.map((category) => {
const isCollapsed = collapsed[category.id];
return (
@@ -76,21 +138,7 @@ export const BlocksPanel: React.FC = () => {
-
- {category.blocks.map((block) => (
-
{ if (ref) connectors.create(ref, block.create()); }}
- onDoubleClick={() => addBlockNode(block, false)}
- onClick={isMobile ? () => handleTapToAdd(block) : undefined}
- title={isMobile ? `Tap to add ${block.label}` : `Drag or double-click to add ${block.label}`}
- >
-
- {block.label}
-
- ))}
-
+
{category.blocks.map(renderTile)}
);
diff --git a/craft/src/panels/left/LeftPanel.test.tsx b/craft/src/panels/left/LeftPanel.test.tsx
new file mode 100644
index 0000000..2097cad
--- /dev/null
+++ b/craft/src/panels/left/LeftPanel.test.tsx
@@ -0,0 +1,39 @@
+import { describe, test, expect, afterEach, beforeEach } from 'vitest';
+import React from 'react';
+import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
+import { PageProvider } from '../../state/PageContext';
+import { LeftPanel } from './LeftPanel';
+
+let h: EditorHarness | null = null;
+beforeEach(() => { window.localStorage.clear(); });
+afterEach(() => { h?.unmount(); h = null; });
+
+function mountPanel(): EditorHarness {
+ const hh = renderEditorHarness();
+ hh.mountChild();
+ return hh;
+}
+const activeTab = (hh: EditorHarness) => hh.container.querySelector('.panel-tab.active')?.textContent;
+
+describe('LeftPanel remembers its tab', () => {
+ test('defaults to Blocks', () => {
+ h = mountPanel();
+ expect(activeTab(h)).toBe('Blocks');
+ });
+
+ test('clicking Pages then remounting shows Pages', () => {
+ h = mountPanel();
+ const pages = Array.from(h.container.querySelectorAll('.panel-tab')).find((b) => b.textContent === 'Pages') as HTMLElement;
+ h.act(() => { pages.click(); });
+ expect(activeTab(h)).toBe('Pages');
+ h.unmount();
+ h = mountPanel();
+ expect(activeTab(h)).toBe('Pages');
+ });
+
+ test('an unknown stored tab falls back to Blocks', () => {
+ window.localStorage.setItem('wsb:leftTab', JSON.stringify('bogus'));
+ h = mountPanel();
+ expect(activeTab(h)).toBe('Blocks');
+ });
+});
diff --git a/craft/src/panels/left/LeftPanel.tsx b/craft/src/panels/left/LeftPanel.tsx
index eb9eae5..0e05ee9 100644
--- a/craft/src/panels/left/LeftPanel.tsx
+++ b/craft/src/panels/left/LeftPanel.tsx
@@ -3,11 +3,17 @@ import { BlocksPanel } from './BlocksPanel';
import { PagesPanel } from './PagesPanel';
import { LayersPanel } from './LayersPanel';
import { AssetsPanel } from './AssetsPanel';
+import { getPref, setPref } from '../../utils/local-prefs';
type LeftTab = 'blocks' | 'pages' | 'layers' | 'assets';
+const TAB_IDS: LeftTab[] = ['blocks', 'pages', 'layers', 'assets'];
export const LeftPanel: React.FC = () => {
- const [activeTab, setActiveTab] = useState('blocks');
+ const [activeTab, setActiveTab] = useState(() => {
+ const saved = getPref('leftTab', 'blocks');
+ return (TAB_IDS as string[]).includes(saved) ? (saved as LeftTab) : 'blocks';
+ });
+ const selectTab = (id: LeftTab) => { setActiveTab(id); setPref('leftTab', id); };
const tabs: { id: LeftTab; label: string }[] = [
{ id: 'blocks', label: 'Blocks' },
@@ -23,7 +29,7 @@ export const LeftPanel: React.FC = () => {
diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css
index adf849c..cf0712a 100644
--- a/craft/src/styles/editor.css
+++ b/craft/src/styles/editor.css
@@ -2184,3 +2184,33 @@ body {
bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom, 0px) + 12px);
}
}
+
+/* --------------------------------------------------------------------------
+ #38 block search
+ -------------------------------------------------------------------------- */
+.block-search {
+ padding: 0 0 10px;
+}
+
+.block-search-input {
+ width: 100%;
+ padding: 7px 10px;
+ font-size: 12px;
+ color: var(--color-text);
+ background: var(--color-bg-base);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ outline: none;
+ transition: border-color var(--transition-fast);
+}
+
+.block-search-input:focus {
+ border-color: var(--color-accent);
+}
+
+.block-search-empty {
+ padding: 16px 4px;
+ font-size: 12px;
+ color: var(--color-text-muted);
+ text-align: center;
+}