feat(site-builder): searchable blocks panel, remembered collapse state and left tab
Refs #38
This commit is contained in:
1 parent
2180f9a776
commit
7b082c8d2e
5 files changed
+266
-24
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user