feat(site-builder): searchable blocks panel, remembered collapse state and left tab

Refs #38
This commit is contained in:
shadowdao committed 2026-10-07 11:23:00 -07:00
1 parent 2180f9a776
commit 7b082c8d2e
5 files changed
+266 -24

No files matched your search

+119
View File
@@ -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(<BlocksPanel />);
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(<div onKeyDown={(e) => seen.push(e.key)}><BlocksPanel /></div>);
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);
});
});
+70 -22
View File
@@ -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<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {};
categories.forEach((cat, index) => {
initial[cat.id] = index !== 0; // First category open
});
categories.forEach((cat) => { initial[cat.id] = getPref<boolean>('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<string, (el: HTMLElement | null) => 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) => (
<div
key={block.id}
className="block-item"
data-block-id={block.id}
ref={dragRefs.get(block.id)}
onDoubleClick={() => addBlockNode(block, false)}
onClick={isMobile ? () => handleTapToAdd(block) : undefined}
title={isMobile ? `Tap to add ${block.label}` : `Drag or double-click to add ${block.label}`}
>
<i className={`fa ${block.icon} block-item-icon`} />
<span className="block-item-label">{block.label}</span>
</div>
);
const onSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
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 (
<div>
{categories.map((category) => {
<div className="block-search">
<input
type="search"
className="block-search-input"
placeholder="Search blocks…"
aria-label="Search blocks"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={onSearchKeyDown}
/>
</div>
{results ? (
results.length > 0 ? (
<div className="block-grid block-search-results">{results.map(renderTile)}</div>
) : (
<div className="block-search-empty">No blocks match "{search.trim()}"</div>
)
) : categories.map((category) => {
const isCollapsed = collapsed[category.id];
return (
<div key={category.id} className="block-category">
@@ -76,21 +138,7 @@ export const BlocksPanel: React.FC = () => {
<i className="fa fa-chevron-down chevron" />
</div>
<div className={`block-category-items ${isCollapsed ? 'collapsed' : ''}`}>
<div className="block-grid">
{category.blocks.map((block) => (
<div
key={block.id}
className="block-item"
ref={(ref) => { 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}`}
>
<i className={`fa ${block.icon} block-item-icon`} />
<span className="block-item-label">{block.label}</span>
</div>
))}
</div>
<div className="block-grid">{category.blocks.map(renderTile)}</div>
</div>
</div>
);
+39
View File
@@ -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(<PageProvider><LeftPanel /></PageProvider>);
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');
});
});
+8 -2
View File
@@ -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<LeftTab>('blocks');
const [activeTab, setActiveTab] = useState<LeftTab>(() => {
const saved = getPref<string>('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 = () => {
<button
key={tab.id}
className={`panel-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
onClick={() => selectTab(tab.id)}
>
{tab.label}
</button>
+30
View File
@@ -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;
}