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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
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
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | Required. Panel content — accepts any children (tabs, search, DragSource lists, forms) |
Also extends HTMLAttributes<HTMLDivElement>.
BuilderMainArea
| Prop | Type | Default | Description |
|---|---|---|---|
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
BuilderLayoutinside aSortableList(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 structure —
BuilderSidePanelaccepts 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.