molecule

BuilderLayout

A layout scaffold for builder interfaces with a side panel and main content area. Combine with SortableList and DragSource for drag-and-drop builders.

Overview

BuilderLayout provides a two-column layout with an independently scrollable side panel and main content area. The side panel can contain anything: tabs, search inputs, DragSource lists, configuration forms, or a mix. The main area supports an optional sticky header with title and actions.

Artifacts
🪪Photo ID
🏥Medical License
📋DEA Registration
📚HIPAA Training
🎓Board Certification
Form Sections
Section 1
Drop artifacts here
import {
  SortableList, SortableGroup, SortableGroupHandle,
  SortableItem, SortableItemHandle, DragSource,
  BuilderLayout, BuilderSidePanel, BuilderMainArea,
} from '@enara-health/ui-react';

const SOURCES = [
  { id: 'photo-id', label: 'Photo ID', icon: '🪪' },
  { id: 'medical-license', label: 'Medical License', icon: '🏥' },
];

let nextId = 1;

const BuilderDemo = () => {
  const [groups, setGroups] = useState([
    { id: 'section-1', title: 'Section 1', itemIds: [] },
  ]);
  const [items, setItems] = useState({}); // itemId → { sourceId, label, icon }

  // Derive used state from current items — no separate tracking
  const usedSourceIds = useMemo(() => {
    const allIds = groups.flatMap((g) => g.itemIds);
    return new Set(allIds.map((id) => items[id]?.sourceId).filter(Boolean));
  }, [groups, items]);

  // External drop — create new instance from DragSource data
  const handleReceiveExternal = (groupId, sourceId, data, insertIndex) => {
    const newId = `item-${nextId++}`;
    setItems((prev) => ({ ...prev, [newId]: { sourceId, ...data } }));
    setGroups((prev) =>
      prev.map((g) => {
        if (g.id !== groupId) return g;
        const ids = [...g.itemIds];
        ids.splice(insertIndex >= 0 ? insertIndex : ids.length, 0, newId);
        return { ...g, itemIds: ids };
      })
    );
  };

  // Remove item — sidebar source becomes available again automatically
  const handleRemoveItem = (groupId, itemId) => {
    setGroups((prev) =>
      prev.map((g) =>
        g.id === groupId
          ? { ...g, itemIds: g.itemIds.filter((id) => id !== itemId) }
          : g
      )
    );
    setItems((prev) => {
      const next = { ...prev };
      delete next[itemId];
      return next;
    });
  };

  return (
    <SortableList id="builder" items={groupIds} onReorder={handleReorder}>
      <BuilderLayout sidePanelWidth={240} sidePanel={
        <BuilderSidePanel>
          {SOURCES.map((s) => (
            <DragSource key={s.id} sourceId={s.id}
              data={{ label: s.label, icon: s.icon }}
              used={usedSourceIds.has(s.id)}>
              <span>{s.icon} {s.label}</span>
            </DragSource>
          ))}
        </BuilderSidePanel>
      }>
        <BuilderMainArea title="Sections" actions={...}>
          {groups.map((g, i) => (
            <SortableGroup key={g.id} id={g.id} index={i}
              items={g.itemIds}
              onReorderItems={...}
              onReceiveItem={...}
              onReceiveExternal={(sid, data, idx) =>
                handleReceiveExternal(g.id, sid, data, idx)
              }
            >
              <SortableGroupHandle><GripIcon /></SortableGroupHandle>
              {g.itemIds.map((id, j) => (
                <SortableItem key={id} id={id} index={j}>
                  <SortableItemHandle><GripIcon /></SortableItemHandle>
                  <span>{items[id]?.label}</span>
                  <button onClick={() => handleRemoveItem(g.id, id)}>
                    <XIcon />
                  </button>
                </SortableItem>
              ))}
            </SortableGroup>
          ))}
        </BuilderMainArea>
      </BuilderLayout>
    </SortableList>
  );
};

Props

BuilderLayout

PropTypeDefaultDescription
sidePanel ReactNode Required. Side panel content — receives BuilderSidePanel or any ReactNode
children ReactNode Required. Main content area
sidePanelWidth number | string 320 Width of the side panel. Number = px, string = CSS value (e.g., '25%')
sidePanelPosition 'left' | 'right' 'left' Position of the side panel

Also extends HTMLAttributes<HTMLDivElement> — pass className, style, etc.

BuilderSidePanel

PropTypeDefaultDescription
children ReactNode Required. Panel content — accepts any children (tabs, search, DragSource lists, forms)

Also extends HTMLAttributes<HTMLDivElement>.

BuilderMainArea

PropTypeDefaultDescription
title ReactNode Optional title displayed at the top of the main area (left side of sticky header)
actions ReactNode Optional actions area (top-right of sticky header), e.g., "Add section" button
children ReactNode Required. Main scrollable content

If neither title nor actions is provided, no header is rendered — the entire area is just scrollable children.

Examples

Builder with DragSource Side Panel

import {
  BuilderLayout, BuilderSidePanel, BuilderMainArea,
  DragSource, SortableList, SortableGroup, SortableGroupHandle,
  SortableItem, SortableItemHandle,
} from '@enara-health/ui-react';

<SortableList id="builder" items={groupIds} onReorder={handleReorder}>
  <BuilderLayout
    sidePanelWidth={320}
    sidePanel={
      <BuilderSidePanel>
        <Tabs tabs={sidePanelTabs} active={tab} onChange={setTab} />
        {tab === 'artifacts' && artifacts.map(a => (
          <DragSource key={a.id} sourceId={a.id} data={a} used={usedIds.has(a.id)}>
            <Card><Icon name={a.icon} /> {a.label}</Card>
          </DragSource>
        ))}
      </BuilderSidePanel>
    }
  >
    <BuilderMainArea
      title={<Text variant="h2">Sections</Text>}
      actions={<Button onClick={addSection}>Add Section</Button>}
    >
      {groups.map((g, i) => (
        <SortableGroup key={g.id} id={g.id} index={i}
          items={g.itemIds} onReceiveExternal={handleDrop(g.id)}>
          <SortableGroupHandle><Icon name="grip-vertical" /></SortableGroupHandle>
          {g.items.map((item, j) => (
            <SortableItem key={item.id} id={item.id} index={j}>
              <SortableItemHandle><Icon name="grip-vertical" /></SortableItemHandle>
              <Text>{item.label}</Text>
            </SortableItem>
          ))}
        </SortableGroup>
      ))}
    </BuilderMainArea>
  </BuilderLayout>
</SortableList>

Right-Side Panel

<BuilderLayout sidePanelPosition="right" sidePanelWidth="25%"
  sidePanel={<BuilderSidePanel>...</BuilderSidePanel>}
>
  <BuilderMainArea>...</BuilderMainArea>
</BuilderLayout>

No Header

{/* No title or actions — entire area is scrollable content */}
<BuilderMainArea>
  <SortableList ...>...</SortableList>
</BuilderMainArea>

Anti-Patterns

  • DragSource outside a DndContext — DragSource and SortableList need a shared DndContext. Wrap BuilderLayout inside a SortableList (which auto-creates the provider).
  • Putting the app header inside BuilderLayout — the header belongs to the consumer, rendered above the layout.
  • Enforcing specific side panel structureBuilderSidePanel accepts any children. Don't hardcode tabs or search.

Accessibility

  • Semantic Structure: Uses standard HTML div elements with logical content flow.
  • Independent Scrolling: Both side panel and main area scroll independently, avoiding content clipping.
  • Keyboard Navigation: All interactive content within both areas is reachable via standard tab navigation.
  • WCAG Compliance: Meets AA standards. Borders use design token colors for sufficient contrast.