import React, { useState } from 'react' import { Type, Mail, AlignLeft, ChevronDown, CheckSquare, CircleDot, Calendar, Hash, Search, Eye, EyeOff, Box, ChevronRight, Columns, Layout, LayoutGrid, Square, User, Phone, Clock, Image, FileText, Code, Send, MapPin, Layers, } from 'lucide-react' import { hasNestedColumns, useFormStore } from '../store/useFormStore' import { applyFilters, doAction, getFieldType, Slot, useRegistryVersion } from '../extensions/registry' import FaIcon from './shell/FaIcon' // the reference builder LayersPanel.jsx getFieldIcon (FA) with lucide fallbacks when Font Awesome is not loaded. const ICONS = { grid: ['fa-th-large', LayoutGrid], section: ['fa-square', Square], text: ['fa-font', Type], email: ['fa-envelope', Mail], textarea: ['fa-align-left', AlignLeft], name: ['fa-user', User], number: ['fa-hashtag', Hash], phone: ['fa-phone', Phone], tel: ['fa-phone', Phone], date: ['fa-calendar', Calendar], time: ['fa-clock', Clock], image: ['fa-image', Image], 'static-content': ['fa-file-alt', FileText], html: ['fa-code', Code], checkbox: ['fa-check-square', CheckSquare], radio: ['fa-dot-circle', CircleDot], dropdown: ['fa-chevron-down', ChevronDown], select: ['fa-chevron-down', ChevronDown], submit: ['fa-paper-plane', Send], hidden: ['fa-eye-slash', EyeOff], address: ['fa-map-marker-alt', MapPin], } const iconFor = (type) => { if (ICONS[type]) return ICONS[type] const registered = getFieldType(type) if (registered && (registered.layerIcon || registered.fa)) return registered.layerIcon || [registered.fa, registered.icon || Box] return ['fa-cube', Box] } /** * Optional prop (App wiring): `onOpenWrapperEditor()` opens the form wrapper style editor. Extensions can group the * field list (`layers.render` filter, which receives the default list element and helpers) and add nodes above or * below it (slots `layers.top` / `layers.bottom`, which receive { specialNode }). */ export default function LayersPanel({ onOpenWrapperEditor }) { useRegistryVersion() const fields = useFormStore((state) => state.fields) const activeFieldId = useFormStore((state) => state.activeFieldId) const setActiveFieldId = useFormStore((state) => state.setActiveFieldId) const hiddenElements = useFormStore((state) => state.hiddenElements) const toggleHiddenElement = useFormStore((state) => state.toggleHiddenElement) const hoveredFieldId = useFormStore((state) => state.hoveredFieldId) const setHoveredFieldId = useFormStore((state) => state.setHoveredFieldId) const [searchTerm, setSearchTerm] = useState('') const [expandedNodes, setExpandedNodes] = useState(new Set()) const toggleExpand = (id, e) => { e.stopPropagation() setExpandedNodes((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } // Extensions can bring the field on screen first (`builder.revealField`), then it scrolls (retries while it renders). const scrollToField = (id, groupId) => { doAction('builder.revealField', id, groupId) let tries = 0 const attempt = () => { const el = document.querySelector(`[data-field-id="${id}"]`) if (!el) { if (tries++ < 5) setTimeout(attempt, 120) return } el.scrollIntoView({ behavior: 'smooth', block: 'center' }) el.classList.add('ring-2', 'ring-blue-500', 'ring-offset-2', 'shadow-[0_0_15px_rgba(59,130,246,0.5)]', 'transition-all', 'duration-500') setTimeout(() => { el.classList.remove('ring-2', 'ring-blue-500', 'ring-offset-2', 'shadow-[0_0_15px_rgba(59,130,246,0.5)]') }, 2000) } attempt() } const nestedIds = (list) => (list || []).flatMap((f) => [f.id, ...(hasNestedColumns(f) ? Object.values(f.columns).flatMap((c) => nestedIds(c)) : [])]) const eyeButton = (hidden, onClick, label) => ( ) const renderField = (field, depth = 0, groupId) => { const [fa, Fallback] = iconFor(field.type) const isHidden = hiddenElements.has(field.id) const isExpanded = expandedNodes.has(field.id) || Boolean(searchTerm) // Containers hold nested fields (a `columns` array holds definitions). const isGrid = hasNestedColumns(field) const name = field.label || field.name || field.type return (
{ setActiveFieldId(field.id); scrollToField(field.id, groupId) }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setActiveFieldId(field.id); scrollToField(field.id, groupId) } }} onMouseEnter={() => setHoveredFieldId(field.id)} onMouseLeave={() => setHoveredFieldId(null)} style={{ paddingLeft: `${12 + depth * 16}px` }} > {isGrid ? ( ) : null} {name} {eyeButton(isHidden, () => toggleHiddenElement(field.id), name)}
{isGrid && isExpanded && field.columns && Object.entries(field.columns).map(([colId, colFields], i) => { const ids = nestedIds(colFields) const colHidden = ids.length > 0 && ids.every((id) => hiddenElements.has(id)) // the reference builder hides every field of the column with one eye. const toggleCol = () => ids.forEach((id) => { if (hiddenElements.has(id) === colHidden) toggleHiddenElement(id) }) return (
{(colFields || []).map((cf) => renderField(cf, depth + 2, groupId))}
) })}
) } // Deep filter on label, name and static content (the reference builder searches content too). const filterFields = (fieldsList) => { if (!searchTerm) return fieldsList const term = searchTerm.toLowerCase() const filterRecursive = (list) => { const result = [] for (const item of list) { const text = `${item.label || ''} ${item.name || ''} ${typeof item.content === 'string' ? item.content.replace(/<[^>]*>/g, ' ') : ''}`.toLowerCase() const matches = text.includes(term) let newCols = null let childMatches = false if (hasNestedColumns(item)) { newCols = {} for (const [colId, colFields] of Object.entries(item.columns)) { const filtered = filterRecursive(colFields || []) if (filtered.length > 0) { childMatches = true newCols[colId] = filtered } } } if (matches || childMatches) result.push(newCols ? { ...item, columns: newCols } : item) } return result } return filterRecursive(fieldsList) } const filteredFields = filterFields(fields) const specialNode = (key, label, Icon, onClick) => (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick() } }} >
) return (

Form Field Layers

{ setActiveFieldId(null); if (onOpenWrapperEditor) onOpenWrapperEditor() }} onKeyDown={(e) => { if (e.key === 'Enter') { setActiveFieldId(null); if (onOpenWrapperEditor) onOpenWrapperEditor() } }} title="Form wrapper style" >
{fields.length === 0 ? (
) : filteredFields.length === 0 ? (
No matching layers.
) : ( applyFilters('layers.render', null, { fields, filteredFields, renderField, searchTerm }) || filteredFields.map((field) => renderField(field, 0)) )}
) }