diff --git a/craft/src/panels/left/layers-virtual-rows.test.ts b/craft/src/panels/left/layers-virtual-rows.test.ts new file mode 100644 index 0000000..9c334e6 --- /dev/null +++ b/craft/src/panels/left/layers-virtual-rows.test.ts @@ -0,0 +1,55 @@ +import { describe, test, expect } from 'vitest'; +import { deriveVirtualRows, VIRTUAL_CHILD_PROPS } from './layers-virtual-rows'; + +describe('deriveVirtualRows', () => { + test('returns one row per item, labelled by the registered field', () => { + const rows = deriveVirtualRows('Features Grid', { + features: [{ title: 'Fast' }, { title: 'Secure' }], + }); + expect(rows).toEqual([ + { index: 0, label: 'Fast' }, + { index: 1, label: 'Secure' }, + ]); + }); + + test('falls back to " N" when the label field is missing or blank', () => { + const rows = deriveVirtualRows('Features Grid', { + features: [{ title: '' }, { description: 'no title key' }], + }); + expect(rows).toEqual([ + { index: 0, label: 'Feature 1' }, + { index: 1, label: 'Feature 2' }, + ]); + }); + + test('trims and truncates a long label to 40 characters with an ellipsis', () => { + const long = 'x'.repeat(60); + const rows = deriveVirtualRows('Features Grid', { features: [{ title: ` ${long} ` }] }); + expect(rows[0].label).toHaveLength(41); + expect(rows[0].label.endsWith('…')).toBe(true); + }); + + test('an unregistered component yields no rows', () => { + expect(deriveVirtualRows('Heading', { text: 'hi' })).toEqual([]); + }); + + test('a missing or non-array prop yields no rows instead of throwing', () => { + expect(deriveVirtualRows('Tabs', {})).toEqual([]); + expect(deriveVirtualRows('Tabs', { tabs: 'not an array' })).toEqual([]); + expect(deriveVirtualRows('Tabs', { tabs: null })).toEqual([]); + }); + + test('a non-object item still gets a fallback label', () => { + expect(deriveVirtualRows('Menu', { links: ['raw string'] })).toEqual([ + { index: 0, label: 'Link 1' }, + ]); + }); + + test('every registry entry has a non-empty prop, label and fallback', () => { + for (const [name, spec] of Object.entries(VIRTUAL_CHILD_PROPS)) { + expect(spec.prop, `${name}.prop`).toBeTruthy(); + expect(spec.label, `${name}.label`).toBeTruthy(); + expect(spec.fallback, `${name}.fallback`).toBeTruthy(); + } + }); +}); diff --git a/craft/src/panels/left/layers-virtual-rows.ts b/craft/src/panels/left/layers-virtual-rows.ts new file mode 100644 index 0000000..c8402a5 --- /dev/null +++ b/craft/src/panels/left/layers-virtual-rows.ts @@ -0,0 +1,73 @@ +/** + * Virtual Layers rows for components whose content lives in ARRAY PROPS + * rather than Craft child nodes. + * + * FeaturesGrid, Tabs, Accordion, PricingTable, Testimonials, Gallery, + * ContentSlider, NumberCounter, Menu, SocialLinks, Navbar and ContactForm all + * render their items from a prop array, so Craft sees them as leaf nodes and + * the Layers tree showed nothing underneath them -- the "Layers doesn't show + * everything" report. These rows are display-only: they are not Craft nodes, + * cannot be dragged, and selecting one selects the PARENT node (plus asks the + * array editor to scroll that item into view -- see LayerFocusContext). + * + * ColumnLayout is deliberately absent: it uses real `` + * children, which LayersPanel already nests correctly. + */ + +export interface VirtualRow { + index: number; + label: string; +} + +export interface VirtualChildSpec { + /** Name of the array prop holding the items. */ + prop: string; + /** Per-item field used as the row label. */ + label: string; + /** Used as " " when the label field is missing or blank. */ + fallback: string; +} + +/** Keyed by the component's craft `displayName` -- the same string + * `LayerNode` already resolves and shows as the row text. + * + * Verified against each component's actual item interface and + * `craft.props` defaults (see task-9-report.md, Step 1): + * - Tabs items are `{ label, content }`, not `{ title }` -- registry + * corrected from `title` to `label`. + * - ContentSlider items are `{ heading, text, ... }`, not `{ title }` -- + * registry corrected from `title` to `heading`. + * Every other entry (prop name and label field) matched the component as + * originally drafted. */ +export const VIRTUAL_CHILD_PROPS: Record = { + 'Features Grid': { prop: 'features', label: 'title', fallback: 'Feature' }, + Tabs: { prop: 'tabs', label: 'label', fallback: 'Tab' }, + Accordion: { prop: 'items', label: 'title', fallback: 'Item' }, + 'Pricing Table': { prop: 'plans', label: 'name', fallback: 'Plan' }, + Testimonials: { prop: 'testimonials', label: 'name', fallback: 'Testimonial' }, + Gallery: { prop: 'images', label: 'alt', fallback: 'Image' }, + 'Content Slider': { prop: 'slides', label: 'heading', fallback: 'Slide' }, + 'Number Counter': { prop: 'counters', label: 'label', fallback: 'Counter' }, + Menu: { prop: 'links', label: 'text', fallback: 'Link' }, + 'Social Links': { prop: 'links', label: 'platform', fallback: 'Link' }, + Navbar: { prop: 'links', label: 'text', fallback: 'Link' }, + 'Contact Form': { prop: 'fields', label: 'label', fallback: 'Field' }, +}; + +const MAX_LABEL = 40; + +export function deriveVirtualRows(displayName: string, props: Record): VirtualRow[] { + const spec = VIRTUAL_CHILD_PROPS[displayName]; + if (!spec) return []; + + const items = props?.[spec.prop]; + if (!Array.isArray(items)) return []; + + return items.map((item, index) => { + const raw = item && typeof item === 'object' ? item[spec.label] : undefined; + const text = typeof raw === 'string' ? raw.trim() : ''; + if (!text) return { index, label: `${spec.fallback} ${index + 1}` }; + const label = text.length > MAX_LABEL ? `${text.slice(0, MAX_LABEL)}…` : text; + return { index, label }; + }); +}