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; +}