Menu builder
Primitives for the QR menu editor — themed canvas preview, layers outline, morphing toolbar, and inspector panels that share the same container-agnostic panel content.
import { MenuBuilderEditor } from '@/components/demos/menu-builder-editor';
export const meta = {
layout: 'fullscreen',
} as const;
export default function MenuBuilderEditorPreview() {
return <MenuBuilderEditor />;
} Dependencies
Source Code
/** Layout variant for items inside a section. */
type MenuItemLayout = 'list' | 'cards' | 'grid' | 'editorial';
/** Theme tokens for the menu being designed (not app chrome). */
interface MenuTheme {
background: string;
textColor: string;
accentColor: string;
mutedColor: string;
dividerColor: string;
radius: string;
headingFamily: string;
headingWeight: number;
headingTracking: string;
headingTransform?: string;
bodyFamily: string;
bodyWeight: number;
bodyTracking: string;
priceFamily: string;
priceWeight: number;
priceTracking: string;
itemLayout: MenuItemLayout;
sectionGap: string;
itemGap: string;
itemPadY: string;
}
interface MenuItemData {
id: string;
name: string;
description?: string;
price?: string;
dietary?: string[];
imageUrl?: string;
}
interface MenuSectionData {
id: string;
title: string;
items: MenuItemData[];
}
/** Top-level editor mode (Presets · Design · Menu). */
type EditorMode = 'presets' | 'design' | 'menu';
type EditorSelection =
| { kind: 'none' }
| { kind: 'brand' }
| { kind: 'section'; id: string }
| { kind: 'item'; sectionId: string; itemId: string };
/** Maps editor selection to a toolbar / inspector panel slot. */
type MenuEditorPanel =
| 'brand'
| 'typography'
| 'layout'
| 'background'
| 'section'
| 'item';
const defaultMenuTheme: MenuTheme = {
background: 'oklch(0.98 0.01 95)',
textColor: 'oklch(0.22 0.02 50)',
accentColor: 'oklch(0.55 0.14 45)',
mutedColor: 'oklch(0.55 0.02 50)',
dividerColor: 'oklch(0.88 0.01 95)',
radius: '1rem',
headingFamily: 'var(--font-sans)',
headingWeight: 600,
headingTracking: '0.02em',
headingTransform: 'none',
bodyFamily: 'var(--font-sans)',
bodyWeight: 400,
bodyTracking: '0',
priceFamily: 'var(--font-sans)',
priceWeight: 500,
priceTracking: '0',
itemLayout: 'list',
sectionGap: '2rem',
itemGap: '0.75rem',
itemPadY: '0.75rem',
};
const sampleMenuSections: MenuSectionData[] = [
{
id: 'starters',
title: 'Starters',
items: [
{
id: 'oysters',
name: 'Oysters natural (6)',
description: 'Lemon, mignonette',
price: '$24',
dietary: ['GF'],
},
{
id: 'bread',
name: 'Sourdough & cultured butter',
price: '$12',
},
],
},
{
id: 'mains',
title: 'Mains',
items: [
{
id: 'fish',
name: 'Grilled barramundi',
description: 'Fennel, capers, olive oil',
price: '$38',
dietary: ['GF'],
},
{
id: 'pasta',
name: 'Hand-cut pappardelle',
description: 'Wild mushroom, parmesan',
price: '$32',
dietary: ['V'],
},
],
},
];
function selectionToPanel(selection: EditorSelection): MenuEditorPanel | null {
switch (selection.kind) {
case 'brand':
return 'brand';
case 'section':
return 'section';
case 'item':
return 'item';
case 'none':
return null;
}
}
function isSelectionActive(
selection: EditorSelection,
target: EditorSelection
): boolean {
if (selection.kind !== target.kind) return false;
if (selection.kind === 'none' || target.kind === 'none') {
return selection.kind === 'none' && target.kind === 'none';
}
if (selection.kind === 'brand' && target.kind === 'brand') return true;
if (selection.kind === 'section' && target.kind === 'section') {
return selection.id === target.id;
}
if (selection.kind === 'item' && target.kind === 'item') {
return (
selection.sectionId === target.sectionId &&
selection.itemId === target.itemId
);
}
return false;
}
export type {
EditorMode,
EditorSelection,
MenuEditorPanel,
MenuItemData,
MenuItemLayout,
MenuSectionData,
MenuTheme,
};
export {
defaultMenuTheme,
isSelectionActive,
sampleMenuSections,
selectionToPanel,
}; 'use client';
import { useMemo } from 'react';
import { menuThemeToStyle } from '@/components/menu-builder/menu-theme-style';
import type { MenuTheme } from '@/components/menu-builder/types';
import { cn } from '@/lib/utils/classnames';
interface MenuThemeProviderProps extends React.ComponentPropsWithRef<'div'> {
theme: MenuTheme;
}
/**
* Projects a {@link MenuTheme} onto `--menu-*` CSS variables for a scoped subtree.
* Menu canvas, sections, and items read variables — never theme props.
*/
const MenuThemeProvider = ({
theme,
className,
style,
ref,
children,
...props
}: MenuThemeProviderProps) => {
const themeStyle = useMemo(() => menuThemeToStyle(theme), [theme]);
return (
<div
ref={ref}
data-menu-theme=""
style={{ ...themeStyle, ...style }}
className={cn(
'bg-(--menu-bg) text-(--menu-text) [font-family:var(--menu-body-family)] [font-weight:var(--menu-body-weight)] [letter-spacing:var(--menu-body-tracking)]',
className
)}
{...props}
>
{children}
</div>
);
};
export type { MenuThemeProviderProps };
export { MenuThemeProvider }; 'use client';
import { cn } from '@/lib/utils/classnames';
interface MenuBuilderLayoutProps extends React.ComponentPropsWithRef<'div'> {}
/** Full-viewport editor shell: header, mode bar, preview stage, control column. */
const MenuBuilderLayout = ({
className,
children,
ref,
...props
}: MenuBuilderLayoutProps) => (
<div
ref={ref}
className={cn(
'flex h-full min-h-0 flex-col overflow-hidden bg-background',
className
)}
{...props}
>
{children}
</div>
);
interface MenuBuilderLayoutHeaderProps
extends React.ComponentPropsWithRef<'header'> {}
const MenuBuilderLayoutHeader = ({
className,
children,
ref,
...props
}: MenuBuilderLayoutHeaderProps) => (
<header
ref={ref}
className={cn(
'flex shrink-0 items-center gap-3 border-border border-b bg-background px-4 py-3',
className
)}
{...props}
>
{children}
</header>
);
interface MenuBuilderLayoutModesProps
extends React.ComponentPropsWithRef<'div'> {}
const MenuBuilderLayoutModes = ({
className,
children,
ref,
...props
}: MenuBuilderLayoutModesProps) => (
<div
ref={ref}
className={cn(
'flex shrink-0 items-center border-border border-b bg-background px-4 py-2.5',
className
)}
{...props}
>
{children}
</div>
);
interface MenuBuilderLayoutWorkspaceProps
extends React.ComponentPropsWithRef<'div'> {}
/** Preview stage + control column. Column on `md+`, stacked on small screens. */
const MenuBuilderLayoutWorkspace = ({
className,
children,
ref,
...props
}: MenuBuilderLayoutWorkspaceProps) => (
<div
ref={ref}
className={cn('flex min-h-0 flex-1 flex-col md:flex-row', className)}
{...props}
>
{children}
</div>
);
interface MenuBuilderLayoutStageProps
extends React.ComponentPropsWithRef<'div'> {}
/**
* Centered preview stage for {@link MenuCanvas}. Fills remaining width/height;
* warm secondary surface so the phone frame reads as the focal point.
*/
const MenuBuilderLayoutStage = ({
className,
children,
ref,
...props
}: MenuBuilderLayoutStageProps) => (
<div
ref={ref}
className={cn(
'relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden',
'bg-background-secondary',
className
)}
{...props}
>
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_60%_at_50%_40%,var(--color-background)_0%,transparent_70%)] opacity-80"
/>
<div className="relative flex min-h-0 flex-1 flex-col">{children}</div>
</div>
);
interface MenuBuilderLayoutControlsProps
extends React.ComponentPropsWithRef<'aside'> {}
/** Right control column (`md+`) or below preview on small screens. */
const MenuBuilderLayoutControls = ({
className,
children,
ref,
...props
}: MenuBuilderLayoutControlsProps) => (
<aside
ref={ref}
className={cn(
'flex min-h-0 w-full shrink-0 flex-col border-border bg-background',
'max-md:border-t md:w-80 md:border-t-0 md:border-l',
'max-md:max-h-[min(40vh,320px)]',
className
)}
{...props}
>
{children}
</aside>
);
const CompoundMenuBuilderLayout = Object.assign(MenuBuilderLayout, {
Header: MenuBuilderLayoutHeader,
Modes: MenuBuilderLayoutModes,
Workspace: MenuBuilderLayoutWorkspace,
Stage: MenuBuilderLayoutStage,
Controls: MenuBuilderLayoutControls,
});
export type {
MenuBuilderLayoutControlsProps,
MenuBuilderLayoutHeaderProps,
MenuBuilderLayoutModesProps,
MenuBuilderLayoutProps,
MenuBuilderLayoutStageProps,
MenuBuilderLayoutWorkspaceProps,
};
export { CompoundMenuBuilderLayout as MenuBuilderLayout }; 'use client';
import { useCallback, useRef } from 'react';
import { MenuSectionView } from '@/components/menu-builder/menu-section-view';
import { MenuThemeProvider } from '@/components/menu-builder/menu-theme-provider';
import type {
EditorSelection,
MenuItemLayout,
MenuSectionData,
MenuTheme,
} from '@/components/menu-builder/types';
import { cn } from '@/lib/utils/classnames';
interface MenuCanvasProps extends React.ComponentPropsWithRef<'div'> {
theme: MenuTheme;
sections: MenuSectionData[];
selection: EditorSelection;
onSelectionChange?: (selection: EditorSelection) => void;
/** Zoom scale for the phone frame. Default `1`. */
zoom?: number;
layout?: MenuItemLayout;
}
/**
* Phone-frame stage for the live menu preview. Clicking empty canvas resets
* selection to `{ kind: 'none' }`.
*/
const MenuCanvas = ({
theme,
sections,
selection,
onSelectionChange,
zoom = 1,
layout,
className,
ref,
...props
}: MenuCanvasProps) => {
const frameRef = useRef<HTMLDivElement>(null);
const itemLayout = layout ?? theme.itemLayout;
const handleCanvasPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!onSelectionChange) return;
const target = e.target as Node;
if (frameRef.current?.contains(target) && target !== frameRef.current) {
return;
}
onSelectionChange({ kind: 'none' });
},
[onSelectionChange]
);
const handleFrameBackgroundClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
onSelectionChange?.({ kind: 'none' });
}
},
[onSelectionChange]
);
return (
<div
ref={ref}
className={cn(
'flex h-full min-h-0 w-full items-center justify-center p-4 sm:p-6 md:p-8',
className
)}
onPointerDown={handleCanvasPointerDown}
{...props}
>
<div
className="flex h-full max-h-full w-full max-w-[min(100%,390px)] flex-col justify-center"
style={{
transform: `scale(${zoom})`,
transformOrigin: 'center center',
}}
>
<div
className={cn(
'flex w-full min-h-[420px] flex-col overflow-hidden rounded-[2rem] border border-border bg-background shadow-(--shadow-raised)'
)}
>
<div className="flex shrink-0 justify-center border-border border-b bg-foreground/3 py-2">
<div className="h-1 w-16 rounded-full bg-foreground/15" />
</div>
<div
ref={frameRef}
className="max-h-[min(72svh,720px)] min-h-0 flex-1 overflow-y-auto overscroll-contain md:max-h-[min(calc(100svh-12rem),720px)]"
>
<MenuThemeProvider
theme={theme}
className="min-h-[480px] p-5"
onClick={handleFrameBackgroundClick}
>
{sections.map((section) => (
<MenuSectionView
key={section.id}
section={section}
layout={itemLayout}
selection={selection}
onSelectSection={(id) =>
onSelectionChange?.({ kind: 'section', id })
}
onSelectItem={(sectionId, itemId) =>
onSelectionChange?.({
kind: 'item',
sectionId,
itemId,
})
}
/>
))}
<button
type="button"
className="mt-4 w-full rounded-(--menu-radius) border border-(--menu-divider) border-dashed py-3 text-(--menu-muted) text-xs"
onClick={(e) => {
e.stopPropagation();
onSelectionChange?.({ kind: 'brand' });
}}
>
Tap to edit brand & theme
</button>
</MenuThemeProvider>
</div>
</div>
</div>
</div>
);
};
export type { MenuCanvasProps };
export { MenuCanvas }; 'use client';
import { MenuItemView } from '@/components/menu-builder/menu-item-view';
import type {
EditorSelection,
MenuItemData,
MenuItemLayout,
MenuSectionData,
} from '@/components/menu-builder/types';
import { cn } from '@/lib/utils/classnames';
interface MenuSectionViewProps extends React.ComponentPropsWithRef<'section'> {
section: MenuSectionData;
layout: MenuItemLayout;
selection: EditorSelection;
onSelectSection?: (sectionId: string) => void;
onSelectItem?: (sectionId: string, itemId: string) => void;
}
const itemGridClass: Record<MenuItemLayout, string> = {
list: 'flex flex-col',
cards: 'grid grid-cols-1 gap-(--menu-item-gap) sm:grid-cols-2',
grid: 'grid grid-cols-2 gap-(--menu-item-gap)',
editorial: 'flex flex-col',
};
const MenuSectionView = ({
section,
layout,
selection,
onSelectSection,
onSelectItem,
className,
ref,
onClick,
...props
}: MenuSectionViewProps) => {
const sectionSelected =
selection.kind === 'section' && selection.id === section.id;
const handleSectionClick = (e: React.MouseEvent<HTMLElement>) => {
onClick?.(e);
if (!e.defaultPrevented) {
e.stopPropagation();
onSelectSection?.(section.id);
}
};
const handleSectionKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {
if (!onSelectSection) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelectSection(section.id);
}
};
return (
// biome-ignore lint/a11y/noStaticElementInteractions: editor block; role=button when selectable
<section
ref={ref}
data-selected={sectionSelected || undefined}
role={onSelectSection ? 'button' : undefined}
tabIndex={onSelectSection ? 0 : undefined}
onClick={onSelectSection ? handleSectionClick : onClick}
onKeyDown={onSelectSection ? handleSectionKeyDown : undefined}
className={cn(
'flex flex-col gap-3',
onSelectSection &&
'focus-within:ring-(length:--ring-width) cursor-pointer rounded-[calc(var(--menu-radius)*0.5)] outline-none ring-ring',
sectionSelected &&
'ring-(--menu-accent) ring-2 ring-offset-(--menu-bg) ring-offset-2',
className
)}
style={{ marginBottom: 'var(--menu-section-gap)' }}
{...props}
>
<h2
className={cn(
'font-[family-name:var(--menu-heading-family)] font-[number:var(--menu-heading-weight)] text-(--menu-text) text-base tracking-[var(--menu-heading-tracking)] [text-transform:var(--menu-heading-transform)]'
)}
>
{section.title}
</h2>
<div className={itemGridClass[layout]}>
{section.items.map((item: MenuItemData) => (
<MenuItemView
key={item.id}
item={item}
layout={layout}
selected={
selection.kind === 'item' &&
selection.sectionId === section.id &&
selection.itemId === item.id
}
onSelect={
onSelectItem
? () => {
onSelectItem(section.id, item.id);
}
: undefined
}
/>
))}
</div>
</section>
);
};
export type { MenuSectionViewProps };
export { MenuSectionView }; 'use client';
import type {
MenuItemData,
MenuItemLayout,
} from '@/components/menu-builder/types';
import { cn } from '@/lib/utils/classnames';
interface MenuItemViewProps extends React.ComponentPropsWithRef<'div'> {
item: MenuItemData;
layout: MenuItemLayout;
selected?: boolean;
onSelect?: () => void;
}
const dietaryClass =
'rounded-full border border-(--menu-divider) px-1.5 py-px text-[10px] text-(--menu-muted)';
const MenuItemView = ({
item,
layout,
selected,
onSelect,
className,
ref,
onClick,
...props
}: MenuItemViewProps) => {
const interactive = Boolean(onSelect);
const handleButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (!e.defaultPrevented) onSelect?.();
};
const content = (() => {
switch (layout) {
case 'cards':
return (
<article className="flex flex-col gap-2 rounded-(--menu-radius) border border-(--menu-divider) p-3">
{item.imageUrl ? (
<div
className="aspect-[4/3] w-full rounded-[calc(var(--menu-radius)*0.75)] bg-(--menu-muted)/15"
style={
item.imageUrl
? {
backgroundImage: `url(${item.imageUrl})`,
backgroundSize: 'cover',
}
: undefined
}
/>
) : null}
<div className="flex items-start justify-between gap-2">
<h3 className="font-[family-name:var(--menu-body-family)] font-[number:var(--menu-body-weight)] text-(--menu-text) text-sm leading-snug">
{item.name}
</h3>
{item.price ? (
<span className="shrink-0 font-[family-name:var(--menu-price-family)] font-[number:var(--menu-price-weight)] text-(--menu-accent) text-sm tabular-nums tracking-[var(--menu-price-tracking)]">
{item.price}
</span>
) : null}
</div>
{item.description ? (
<p className="text-(--menu-muted) text-xs leading-relaxed">
{item.description}
</p>
) : null}
{item.dietary?.length ? (
<div className="flex flex-wrap gap-1">
{item.dietary.map((tag) => (
<span key={tag} className={dietaryClass}>
{tag}
</span>
))}
</div>
) : null}
</article>
);
case 'grid':
return (
<article className="flex flex-col gap-1 py-(--menu-item-pad-y)">
<div className="flex items-baseline justify-between gap-2">
<h3 className="font-[family-name:var(--menu-body-family)] text-(--menu-text) text-sm">
{item.name}
</h3>
{item.price ? (
<span className="font-[family-name:var(--menu-price-family)] font-[number:var(--menu-price-weight)] text-(--menu-accent) text-sm tabular-nums">
{item.price}
</span>
) : null}
</div>
{item.description ? (
<p className="text-(--menu-muted) text-xs">{item.description}</p>
) : null}
</article>
);
case 'editorial':
return (
<article className="flex flex-col gap-2 border-(--menu-divider) border-b py-(--menu-item-pad-y) last:border-b-0">
<h3 className="font-[family-name:var(--menu-heading-family)] font-[number:var(--menu-heading-weight)] text-(--menu-text) text-lg tracking-[var(--menu-heading-tracking)] [text-transform:var(--menu-heading-transform)]">
{item.name}
</h3>
{item.description ? (
<p className="max-w-prose text-(--menu-muted) text-sm leading-relaxed">
{item.description}
</p>
) : null}
<div className="flex flex-wrap items-center gap-2">
{item.price ? (
<span className="font-[family-name:var(--menu-price-family)] text-(--menu-accent) text-base tabular-nums">
{item.price}
</span>
) : null}
{item.dietary?.map((tag) => (
<span key={tag} className={dietaryClass}>
{tag}
</span>
))}
</div>
</article>
);
default:
return (
<article className="flex gap-3 py-(--menu-item-pad-y)">
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<h3 className="font-[family-name:var(--menu-body-family)] text-(--menu-text) text-sm">
{item.name}
</h3>
{item.price ? (
<span className="shrink-0 font-[family-name:var(--menu-price-family)] font-[number:var(--menu-price-weight)] text-(--menu-accent) text-sm tabular-nums">
{item.price}
</span>
) : null}
</div>
{item.description ? (
<p className="mt-0.5 text-(--menu-muted) text-xs leading-relaxed">
{item.description}
</p>
) : null}
{item.dietary?.length ? (
<div className="mt-1.5 flex flex-wrap gap-1">
{item.dietary.map((tag) => (
<span key={tag} className={dietaryClass}>
{tag}
</span>
))}
</div>
) : null}
</div>
</article>
);
}
})();
const ring = (
<div
className={cn(
'rounded-[calc(var(--menu-radius)*0.5)] transition-shadow duration-(--duration-hover) ease-(--ease)',
selected &&
'ring-(--menu-accent) ring-2 ring-offset-(--menu-bg) ring-offset-2'
)}
>
{content}
</div>
);
if (interactive) {
return (
<button
ref={ref as React.Ref<HTMLButtonElement>}
type="button"
data-selected={selected || undefined}
onClick={handleButtonClick}
className={cn(
'focus-visible:ring-(length:--ring-width) w-full cursor-pointer rounded-[calc(var(--menu-radius)*0.5)] text-left outline-none ring-ring',
className
)}
>
{ring}
</button>
);
}
return (
<div
ref={ref}
data-selected={selected || undefined}
onClick={onClick}
className={className}
{...props}
>
{ring}
</div>
);
};
export type { MenuItemViewProps };
export { MenuItemView }; 'use client';
import { ChevronRight, Palette } from '@untitledui-pro/icons/solid';
import { ListItem } from '@/components/list-item';
import type {
EditorSelection,
MenuSectionData,
} from '@/components/menu-builder/types';
import { isSelectionActive } from '@/components/menu-builder/types';
import { cn } from '@/lib/utils/classnames';
interface SectionTreeProps extends React.ComponentPropsWithRef<'nav'> {
sections: MenuSectionData[];
selection: EditorSelection;
onSelectionChange?: (selection: EditorSelection) => void;
venueName?: string;
}
const SectionTree = ({
sections,
selection,
onSelectionChange,
venueName = 'Brand',
className,
ref,
...props
}: SectionTreeProps) => {
const select = (next: EditorSelection) => {
onSelectionChange?.(next);
};
return (
<nav
ref={ref}
aria-label="Menu outline"
className={cn('flex flex-col gap-1', className)}
{...props}
>
<ListItem
asChild
interactive
size="sm"
active={isSelectionActive(selection, { kind: 'brand' })}
>
<button type="button" onClick={() => select({ kind: 'brand' })}>
<ListItem.Leading>
<Palette className="size-4" aria-hidden />
</ListItem.Leading>
<ListItem.Content>
<ListItem.Title>{venueName}</ListItem.Title>
<ListItem.Subtitle>Colours & typography</ListItem.Subtitle>
</ListItem.Content>
</button>
</ListItem>
{sections.map((section) => {
const sectionActive = isSelectionActive(selection, {
kind: 'section',
id: section.id,
});
return (
<div key={section.id} className="flex flex-col gap-0.5">
<ListItem asChild interactive size="sm" active={sectionActive}>
<button
type="button"
onClick={() => select({ kind: 'section', id: section.id })}
>
<ListItem.Leading>
<ChevronRight
className={cn(
'size-4 transition-transform duration-(--duration-hover) ease-(--ease)',
sectionActive && 'rotate-90'
)}
aria-hidden
/>
</ListItem.Leading>
<ListItem.Content>
<ListItem.Title>{section.title}</ListItem.Title>
</ListItem.Content>
</button>
</ListItem>
{(sectionActive || selection.kind === 'item') &&
section.items.map((item) => (
<ListItem
key={item.id}
asChild
interactive
size="sm"
active={isSelectionActive(selection, {
kind: 'item',
sectionId: section.id,
itemId: item.id,
})}
className="pl-8"
>
<button
type="button"
onClick={() =>
select({
kind: 'item',
sectionId: section.id,
itemId: item.id,
})
}
>
<ListItem.Content>
<ListItem.Title className="font-normal text-sm">
{item.name}
</ListItem.Title>
</ListItem.Content>
</button>
</ListItem>
))}
</div>
);
})}
</nav>
);
};
export type { SectionTreeProps };
export { SectionTree }; 'use client';
import {
Colors,
Grid01,
Image01,
LayersTwo01,
Settings01,
Tag01,
} from '@untitledui-pro/icons/solid';
import type { MenuEditorPanel } from '@/components/menu-builder/types';
import { ToolbarMenu } from '@/components/toolbar-menu';
import { cn } from '@/lib/utils/classnames';
const PANEL_INDEX: Record<MenuEditorPanel, number> = {
brand: 0,
typography: 1,
layout: 2,
background: 3,
section: 4,
item: 5,
};
const INDEX_TO_PANEL = Object.entries(PANEL_INDEX) as Array<
[MenuEditorPanel, number]
>;
function panelToIndex(panel: MenuEditorPanel | null): number | null {
if (panel === null) return null;
return PANEL_INDEX[panel];
}
function indexToPanel(index: number | null): MenuEditorPanel | null {
if (index === null) return null;
const found = INDEX_TO_PANEL.find(([, i]) => i === index);
return found?.[0] ?? null;
}
interface MenuToolbarTab {
panel: MenuEditorPanel;
label: string;
icon: React.ReactNode;
}
const defaultTabs: MenuToolbarTab[] = [
{ panel: 'brand', label: 'Brand', icon: <Colors className="size-4" /> },
{
panel: 'typography',
label: 'Type',
icon: <Settings01 className="size-4" />,
},
{ panel: 'layout', label: 'Layout', icon: <Grid01 className="size-4" /> },
{
panel: 'background',
label: 'Background',
icon: <Image01 className="size-4" />,
},
{
panel: 'section',
label: 'Section',
icon: <LayersTwo01 className="size-4" />,
},
{ panel: 'item', label: 'Item', icon: <Tag01 className="size-4" /> },
];
interface MenuToolbarProps extends React.ComponentPropsWithRef<'div'> {
/** Which settings panel is open in the morphing toolbar. */
activePanel: MenuEditorPanel | null;
onActivePanelChange?: (panel: MenuEditorPanel | null) => void;
/** Panel body keyed by slot — same content as MenuInspector. */
panels: Partial<Record<MenuEditorPanel, React.ReactNode>>;
tabs?: MenuToolbarTab[];
}
/**
* Floating bottom toolbar for the menu editor. Wraps {@link ToolbarMenu} and maps
* editor panel slots to morphing tabs. Panel content is container-agnostic.
*/
const MenuToolbar = ({
activePanel,
onActivePanelChange,
panels,
tabs = defaultTabs,
className,
ref,
...props
}: MenuToolbarProps) => {
const selectedIndex = panelToIndex(activePanel);
return (
<ToolbarMenu
ref={ref}
className={cn(className)}
selected={selectedIndex}
onSelectedChange={(index) => onActivePanelChange?.(indexToPanel(index))}
closeOnOutsideClick={false}
{...props}
>
<ToolbarMenu.Container>
<ToolbarMenu.Panels>
{tabs.map((tab) => (
<ToolbarMenu.Panel key={tab.panel} index={PANEL_INDEX[tab.panel]}>
<div className="flex min-h-28 flex-col gap-3 p-3">
{panels[tab.panel] ?? (
<p className="text-foreground/50 text-xs">
No panel content for {tab.label}.
</p>
)}
</div>
</ToolbarMenu.Panel>
))}
</ToolbarMenu.Panels>
<ToolbarMenu.Bar>
<ToolbarMenu.Tabs>
{tabs.map((tab) => (
<ToolbarMenu.Tab
key={tab.panel}
index={PANEL_INDEX[tab.panel]}
icon={tab.icon}
label={tab.label}
/>
))}
</ToolbarMenu.Tabs>
</ToolbarMenu.Bar>
</ToolbarMenu.Container>
</ToolbarMenu>
);
};
export type { MenuToolbarProps, MenuToolbarTab };
export { MenuToolbar, PANEL_INDEX, panelToIndex }; 'use client';
import { Inspector } from '@/components/inspector';
import type { MenuEditorPanel } from '@/components/menu-builder/types';
import { cn } from '@/lib/utils/classnames';
const PANEL_TITLES: Record<MenuEditorPanel, string> = {
brand: 'Brand',
typography: 'Typography',
layout: 'Layout',
background: 'Background',
section: 'Section',
item: 'Item',
};
interface MenuInspectorProps extends React.ComponentPropsWithRef<'section'> {
activePanel: MenuEditorPanel | null;
panels: Partial<Record<MenuEditorPanel, React.ReactNode>>;
title?: string;
icon?: React.ReactNode;
defaultPinned?: boolean;
}
/**
* Right-side inspector for the menu editor. Renders the same panel content as
* {@link MenuToolbar} — never fork panel logic per container.
*/
const MenuInspector = ({
activePanel,
panels,
title,
icon,
defaultPinned = true,
className,
ref,
...props
}: MenuInspectorProps) => {
const resolvedTitle =
title ?? (activePanel ? PANEL_TITLES[activePanel] : 'Inspector');
return (
<Inspector
ref={ref}
defaultPinned={defaultPinned}
className={cn(className)}
{...props}
>
<Inspector.Handle />
<Inspector.Panel>
<Inspector.Header title={resolvedTitle} icon={icon} showClose />
<Inspector.Content>
{activePanel && panels[activePanel] ? (
panels[activePanel]
) : (
<p className="text-foreground-secondary text-sm">
Select brand, a section, or an item on the canvas to edit
properties.
</p>
)}
</Inspector.Content>
</Inspector.Panel>
</Inspector>
);
};
export type { MenuInspectorProps };
export { MenuInspector }; import { cn } from '@/lib/utils/classnames';
interface PanelSectionProps extends React.ComponentPropsWithRef<'div'> {
label?: string;
}
/** Labelled group for toolbar / inspector panel content. Parent owns outer gap. */
const PanelSection = ({
label,
className,
children,
ref,
...props
}: PanelSectionProps) => (
<div ref={ref} className={cn('flex flex-col gap-2', className)} {...props}>
{label ? (
<p className="font-medium text-[10px] text-foreground/50 uppercase tracking-wider">
{label}
</p>
) : null}
{children}
</div>
);
interface PanelRowProps extends React.ComponentPropsWithRef<'div'> {
label: string;
}
/** Compact settings row: label left, control right. */
const PanelRow = ({
label,
className,
children,
ref,
...props
}: PanelRowProps) => (
<div
ref={ref}
className={cn('flex items-center justify-between gap-3 text-xs', className)}
{...props}
>
<span className="text-foreground/60">{label}</span>
<div className="flex min-w-0 shrink-0 items-center gap-1">{children}</div>
</div>
);
export type { PanelRowProps, PanelSectionProps };
export { PanelRow, PanelSection }; Overview
These primitives implement the visual editor described in the Playstack QR menu product brief: preview-first layout (centered phone stage, right control column on desktop), layers in a drawer, and three editor modes (Presets · Design · Menu). Toolbar / inspector panels are a later pass.
Menu chrome (sidebar, header, publish) uses library tokens. The menu preview reads
--menu-* variables from MenuThemeProvider.
Primitives
| Export | Role |
|---|---|
MenuThemeProvider | Maps MenuTheme → --menu-* CSS variables |
MenuBuilderLayout | Editor shell — header, modes, stage, control column |
MenuCanvas | Phone-frame preview + selection (lives in Stage) |
MenuSectionView / MenuItemView | Published menu surface (list, cards, grid, editorial) |
SectionTree | Left outline synced to EditorSelection |
MenuToolbar | Bottom morphing toolbar (wraps ToolbarMenu) |
MenuInspector | Right panel (wraps Inspector) |
PanelSection / PanelRow | Shared panel layout rhythm |
buildMenuBuilderPanels | Example panel bodies for demos |
Selection model
type EditorSelection =
| { kind: 'none' }
| { kind: 'brand' }
| { kind: 'section'; id: string }
| { kind: 'item'; sectionId: string; itemId: string };
Use selectionToPanel() to open the matching toolbar / inspector slot.
Editor preview
import { MenuBuilderEditor } from '@/components/demos/menu-builder-editor';
export const meta = {
layout: 'fullscreen',
} as const;
export default function MenuBuilderEditorPreview() {
return <MenuBuilderEditor />;
} Open these first:
- Full-page demo (recommended): /demos/menu-builder
- Docs preview frame: /preview/menu-builder-editor
Both show the same editor — layers rail, phone canvas, morphing toolbar, and inspector with shared panel content.
Previous
Menu
Next
Modal