Blocks

App Navigation Builder Block

CMS UI

A copyable tree-first workspace for editing product navigation, role-based visibility, destination metadata, and publish-ready menu payloads.

Navigation

App navigation builder

Copy this into admin settings, CMS tooling, customer portals, app builders, or internal platforms that let teams manage nested product navigation.

1200px

<script setup>
import { computed, ref } from 'vue';
import {
	DomButton,
	DomDialog,
	DomListbox,
	DomTagCombobox,
	DomTextInput,
	DomToggle,
	DomToggleButtonGroup,
	DomTooltip,
	DomTreeView,
} from '@getdom/studio/vue';
import NavigationStatusPill from '../components/NavigationStatusPill.vue';
import PreviewMenuItem from '../components/PreviewMenuItem.vue';

const icons = {
	home: 'M4 11.5 12 5l8 6.5V20h-5v-5H9v5H4v-8.5Z',
	folder: 'M4 6h6l2 2h8v10H4V6Z',
	file: 'M6 4h8l4 4v12H6V4Zm8 0v5h5',
	chart: 'M5 19V5m0 14h14M9 16v-5m4 5V8m4 8v-7',
	card: 'M4 7h16v10H4V7Zm0 3h16M8 14h3',
	bolt: 'M13 3 5 14h6l-1 7 9-12h-6l1-6Z',
	lock: 'M7 10V8a5 5 0 0 1 10 0v2m-11 0h12v10H6V10Zm6 4v2',
};

const visibilityOptions = [
	{
		label: 'Everyone',
		value: 'everyone',
		description: 'Visible to signed-in users with access to the app shell.',
		count: 'Default',
	},
	{
		label: 'Role gated',
		value: 'role-gated',
		description: 'Visible only when the user matches selected roles or plan rules.',
		count: 'Policy',
	},
	{
		label: 'Admins only',
		value: 'admins',
		description: 'Visible to owners and admin roles for sensitive workspace areas.',
		count: 'Locked',
	},
	{
		label: 'Hidden draft',
		value: 'hidden',
		description: 'Kept in the draft tree but excluded from the published menu.',
		count: 'Draft',
	},
];

const visibilityLabelByValue = Object.fromEntries(visibilityOptions.map((option) => [option.value, option.label]));

const audienceOptions = [
	{ label: 'All users', value: 'all-users', description: 'Default app audience' },
	{ label: 'Workspace admins', value: 'workspace-admins', description: 'Owner and admin roles' },
	{ label: 'Billing managers', value: 'billing-managers', description: 'Can manage invoices and plans' },
	{ label: 'Support agents', value: 'support-agents', description: 'Can view customer tools' },
	{ label: 'Beta testers', value: 'beta-testers', description: 'Feature-flagged preview group' },
	{ label: 'Enterprise plan', value: 'enterprise-plan', description: 'Plan-gated customers' },
];

const previewModeOptions = [
	{ label: 'Desktop', value: 'desktop' },
	{ label: 'Mobile', value: 'mobile' },
	{ label: 'Portal', value: 'portal' },
];

const navItems = ref([
	{
		id: 'overview',
		label: 'Overview',
		url: '/app',
		icon: icons.home,
		visibility: 'everyone',
		visibilityLabel: 'Everyone',
		audience: ['all-users'],
		featureFlag: '',
		requiresAuth: true,
		description: 'Primary landing route after sign in.',
		open: true,
		children: [
			{
				id: 'dashboard',
				label: 'Dashboard',
				url: '/app/dashboard',
				icon: icons.chart,
				visibility: 'everyone',
				visibilityLabel: 'Everyone',
				audience: ['all-users'],
				featureFlag: '',
				requiresAuth: true,
				description: 'Team summary, recent activity, and shortcuts.',
			},
			{
				id: 'reports',
				label: 'Reports',
				url: '/app/reports',
				icon: icons.file,
				visibility: 'role-gated',
				visibilityLabel: 'Role gated',
				audience: ['workspace-admins', 'enterprise-plan'],
				featureFlag: 'reports_v2',
				requiresAuth: true,
				description: 'Saved reports and scheduled exports.',
			},
		],
	},
	{
		id: 'operations',
		label: 'Operations',
		url: '/app/ops',
		icon: icons.folder,
		visibility: 'role-gated',
		visibilityLabel: 'Role gated',
		audience: ['workspace-admins', 'support-agents'],
		featureFlag: '',
		requiresAuth: true,
		description: 'Internal queues and customer workflows.',
		open: true,
		children: [
			{
				id: 'customers',
				label: 'Customers',
				url: '/app/ops/customers',
				icon: icons.file,
				visibility: 'role-gated',
				visibilityLabel: 'Role gated',
				audience: ['support-agents', 'workspace-admins'],
				featureFlag: '',
				requiresAuth: true,
				description: 'Customer records, health, and activity.',
			},
			{
				id: 'automation',
				label: 'Automation',
				url: '/app/ops/automation',
				icon: icons.bolt,
				visibility: 'hidden',
				visibilityLabel: 'Hidden draft',
				audience: ['beta-testers'],
				featureFlag: 'automation_builder',
				requiresAuth: true,
				description: 'Draft workflow builder for beta teams.',
			},
		],
	},
	{
		id: 'billing',
		label: 'Billing',
		url: '/app/billing',
		icon: icons.card,
		visibility: 'admins',
		visibilityLabel: 'Admins only',
		audience: ['workspace-admins', 'billing-managers'],
		featureFlag: '',
		requiresAuth: true,
		description: 'Plans, invoices, payment methods, and usage.',
	},
]);

const selectedItemId = ref('reports');
const previewMode = ref('desktop');
const draftStatus = ref('unsaved');
const publishDialogOpen = ref(false);
const lastPublishedAt = ref('Not published from this draft');
const changedIds = ref(['reports', 'automation']);

const selectedItem = computed(() => findItem(navItems.value, selectedItemId.value) || navItems.value[0]);
const selectedPath = computed(() => itemPath(navItems.value, selectedItemId.value).map((item) => item.label).join(' / '));
const totalItemCount = computed(() => countItems(navItems.value));
const hiddenItemCount = computed(() => flattenItems(navItems.value).filter((item) => item.visibility === 'hidden').length);
const gatedItemCount = computed(() => flattenItems(navItems.value).filter((item) => item.visibility !== 'everyone').length);
const publishPayload = computed(() => ({
	draftId: 'nav_draft_0924',
	status: draftStatus.value,
	selectedItemId: selectedItemId.value,
	previewMode: previewMode.value,
	items: flattenItems(navItems.value).map((item, index) => ({
		id: item.id,
		label: item.label,
		url: item.url,
		visibility: item.visibility,
		audience: item.audience,
		featureFlag: item.featureFlag || null,
		requiresAuth: item.requiresAuth,
		sortOrder: (index + 1) * 10,
	})),
	changedIds: changedIds.value,
}));

function findItem(items, id) {
	for (const item of items) {
		if (item.id === id) return item;
		const child = findItem(item.children || [], id);
		if (child) return child;
	}
	return null;
}

function itemPath(items, id, parents = []) {
	for (const item of items) {
		const nextParents = [...parents, item];
		if (item.id === id) return nextParents;
		const childPath = itemPath(item.children || [], id, nextParents);
		if (childPath.length) return childPath;
	}
	return [];
}

function flattenItems(items) {
	return items.flatMap((item) => [item, ...flattenItems(item.children || [])]);
}

function countItems(items) {
	return flattenItems(items).length;
}

function markChanged(id = selectedItemId.value) {
	if (!changedIds.value.includes(id)) changedIds.value = [...changedIds.value, id];
	draftStatus.value = 'unsaved';
}

function onTreeItemsUpdate(items) {
	navItems.value = syncVisibilityLabels(items);
	markChanged(selectedItemId.value);
}

function syncVisibilityLabels(items) {
	return items.map((item) => ({
		...item,
		visibilityLabel: visibilityLabelByValue[item.visibility] || item.visibility,
		children: item.children ? syncVisibilityLabels(item.children) : undefined,
	}));
}

function updateVisibility(value) {
	selectedItem.value.visibility = value;
	selectedItem.value.visibilityLabel = visibilityLabelByValue[value] || value;
	if (value === 'everyone') selectedItem.value.audience = ['all-users'];
	if (value === 'hidden') selectedItem.value.requiresAuth = true;
	markChanged();
}

function selectItem(id) {
	selectedItemId.value = id;
}

function publishDraft() {
	draftStatus.value = 'published';
	changedIds.value = [];
	lastPublishedAt.value = 'Published just now by Maya Chen';
	publishDialogOpen.value = false;
}

function statusTone(value) {
	if (value === 'everyone') return 'success';
	if (value === 'hidden') return 'warning';
	if (value === 'admins') return 'destructive';
	return 'primary';
}
</script>

<template>
	<div class="w-full overflow-hidden border border-border bg-background text-fg shadow-2xl shadow-black/10">
		<header class="border-b border-border skin-raised px-5 py-5 sm:px-7">
			<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
				<div class="min-w-0">
					<p class="text-xs font-semibold uppercase text-muted-fg">Navigation CMS</p>
					<h3 class="mt-2 text-2xl font-semibold">App navigation builder</h3>
					<p class="mt-2 max-w-3xl text-sm leading-6 text-muted-fg">
						Edit nested menu structure, route metadata, feature gates, and audience rules before publishing a versioned app menu.
					</p>
				</div>
				<div class="flex flex-wrap items-center gap-2">
					<NavigationStatusPill :tone="draftStatus === 'published' ? 'success' : 'warning'">
						{{ draftStatus === 'published' ? 'Published' : 'Unsaved draft' }}
					</NavigationStatusPill>
					<DomTooltip text="Publish creates a versioned navigation draft that your app shell can fetch at runtime.">
						<DomButton size="sm" @click="publishDialogOpen = true">Review publish</DomButton>
					</DomTooltip>
				</div>
			</div>

			<div class="mt-5 grid gap-3 md:grid-cols-3">
				<div class="border-l-4 border-primary bg-primary/10 px-4 py-3">
					<p class="text-xs font-medium text-muted-fg">Menu items</p>
					<p class="mt-1 text-2xl font-semibold">{{ totalItemCount }}</p>
					<p class="mt-1 text-xs leading-5 text-muted-fg">Drag the tree or select a row to edit metadata.</p>
				</div>
				<div class="border-l-4 border-warning bg-warning/10 px-4 py-3">
					<p class="text-xs font-medium text-muted-fg">Gated links</p>
					<p class="mt-1 text-2xl font-semibold">{{ gatedItemCount }}</p>
					<p class="mt-1 text-xs leading-5 text-muted-fg">Role, admin, or draft-only routes need policy checks.</p>
				</div>
				<div class="border-l-4 border-success bg-success/10 px-4 py-3">
					<p class="text-xs font-medium text-muted-fg">Publish state</p>
					<p class="mt-1 text-lg font-semibold">{{ changedIds.length }} changed</p>
					<p class="mt-1 text-xs leading-5 text-muted-fg">{{ lastPublishedAt }}</p>
				</div>
			</div>
		</header>

		<main class="grid gap-0 xl:grid-cols-[minmax(19rem,0.9fr)_minmax(0,1.35fr)]">
			<section class="border-b border-border p-4 sm:p-5 xl:border-b-0 xl:border-r">
				<div class="flex items-start justify-between gap-3">
					<div>
						<h4 class="font-semibold">Menu hierarchy</h4>
						<p class="mt-1 text-sm leading-6 text-muted-fg">Keyboard navigable tree with nested drag/drop reordering.</p>
					</div>
					<NavigationStatusPill tone="neutral">{{ hiddenItemCount }} hidden</NavigationStatusPill>
				</div>

				<DomTreeView
					v-model="selectedItemId"
					v-model:items="navItems"
					class="mt-4"
					label="App navigation hierarchy"
					:open-values="['overview', 'operations']"
					variant="finder"
					density="comfortable"
					@update:items="onTreeItemsUpdate"
					@select="selectItem($event.value)"
				>
					<template #item="{ item, selected }">
						<span class="flex min-w-0 flex-1 items-center justify-between gap-3">
							<span class="min-w-0">
								<span class="block truncate text-sm font-semibold" :class="selected ? 'text-fg' : 'text-muted-fg'">{{ item.label }}</span>
								<span class="mt-0.5 block truncate text-xs text-muted-fg">{{ item.url }}</span>
							</span>
							<span
								class="rounded-full px-2 py-0.5 text-[11px] font-semibold"
								:class="{
									'bg-success/10 text-success': item.visibility === 'everyone',
									'bg-primary/10 text-primary': item.visibility === 'role-gated',
									'bg-warning/10 text-warning': item.visibility === 'hidden',
									'bg-destructive/10 text-destructive': item.visibility === 'admins'
								}"
							>
								{{ item.visibilityLabel }}
							</span>
						</span>
					</template>
				</DomTreeView>

				<div class="mt-4 border border-dashed border-border bg-secondary/40 p-4 text-sm leading-6 text-muted-fg">
					<p class="font-medium text-fg">Selected path</p>
					<p class="mt-1">{{ selectedPath }}</p>
				</div>
			</section>

			<section class="grid gap-0 lg:grid-cols-[minmax(0,1fr)_22rem]">
				<div class="border-b border-border p-4 sm:p-5 lg:border-b-0 lg:border-r">
					<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
						<div class="min-w-0">
							<p class="text-xs font-semibold uppercase text-muted-fg">Selected item</p>
							<h4 class="mt-1 text-xl font-semibold">{{ selectedItem.label }}</h4>
							<p class="mt-1 text-sm leading-6 text-muted-fg">{{ selectedItem.description }}</p>
						</div>
						<NavigationStatusPill :tone="statusTone(selectedItem.visibility)">
							{{ selectedItem.visibilityLabel }}
						</NavigationStatusPill>
					</div>

					<div class="mt-5 grid gap-4">
						<div class="grid gap-4 md:grid-cols-2">
							<DomTextInput
								v-model="selectedItem.label"
								label="Menu label"
								placeholder="Reports"
								@update:model-value="markChanged()"
							/>
							<DomTextInput
								v-model="selectedItem.url"
								label="Route path"
								placeholder="/app/reports"
								@update:model-value="markChanged()"
							/>
						</div>

						<DomTextInput
							v-model="selectedItem.description"
							label="Internal description"
							placeholder="Explain where this item sends people"
							@update:model-value="markChanged()"
						/>

						<DomListbox
							:model-value="selectedItem.visibility"
							label="Visibility rule"
							:options="visibilityOptions"
							@update:model-value="updateVisibility"
						>
							<template #option="{ option }">
								<span class="flex items-start justify-between gap-3">
									<span>
										<span class="block font-semibold">{{ option.label }}</span>
										<span class="mt-0.5 block text-xs leading-5 opacity-80">{{ option.description }}</span>
									</span>
									<span class="rounded-full bg-background/80 px-2 py-0.5 text-[11px] font-semibold">{{ option.count }}</span>
								</span>
							</template>
						</DomListbox>

						<DomTagCombobox
							v-model="selectedItem.audience"
							label="Audience tags"
							:options="audienceOptions"
							placeholder="Add roles, plans, or cohorts..."
							clearable
							@change="markChanged()"
						>
							<template #item="{ item }">
								<span class="block">
									<span class="block text-sm font-semibold">{{ item.label }}</span>
									<span class="block text-xs text-muted-fg">{{ item.description }}</span>
								</span>
							</template>
						</DomTagCombobox>

						<div class="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
							<DomTextInput
								v-model="selectedItem.featureFlag"
								label="Feature flag key"
								placeholder="reports_v2"
								@update:model-value="markChanged()"
							/>
							<DomToggle
								v-model="selectedItem.requiresAuth"
								label="Requires sign in"
								@update:model-value="markChanged()"
							/>
						</div>
					</div>

					<div class="mt-5 grid gap-3 rounded-lg border border-border bg-secondary/40 p-4 text-sm leading-6">
						<div class="flex items-center justify-between gap-3">
							<p class="font-semibold">Publish readiness</p>
							<NavigationStatusPill :tone="selectedItem.url.startsWith('/') ? 'success' : 'warning'">
								{{ selectedItem.url.startsWith('/') ? 'Route looks valid' : 'Check route' }}
							</NavigationStatusPill>
						</div>
						<ul class="grid gap-2 text-muted-fg">
							<li class="flex items-center gap-2">
								<span class="size-2 rounded-full" :class="selectedItem.label ? 'bg-success' : 'bg-warning'"></span>
								Label is ready for navigation display.
							</li>
							<li class="flex items-center gap-2">
								<span class="size-2 rounded-full" :class="selectedItem.visibility === 'everyone' || selectedItem.audience.length ? 'bg-success' : 'bg-warning'"></span>
								Audience rule has at least one selected cohort.
							</li>
							<li class="flex items-center gap-2">
								<span class="size-2 rounded-full" :class="selectedItem.requiresAuth ? 'bg-success' : 'bg-warning'"></span>
								Protected routes should still enforce server authorization.
							</li>
						</ul>
					</div>
				</div>

				<aside class="skin-raised p-4 sm:p-5">
					<div class="flex items-start justify-between gap-3">
						<div>
							<h4 class="font-semibold">Live preview</h4>
							<p class="mt-1 text-sm leading-6 text-muted-fg">Switch viewport targets before publishing.</p>
						</div>
					</div>

					<DomToggleButtonGroup
						v-model="previewMode"
						class="mt-4"
						label="Preview mode"
						:options="previewModeOptions"
						size="sm"
						@update:model-value="markChanged()"
					/>

					<div
						class="mt-4 overflow-hidden border border-border bg-background shadow-lg"
						:class="previewMode === 'mobile' ? 'mx-auto max-w-[18rem] rounded-lg p-2' : 'rounded-lg'"
					>
						<div class="border-b border-border px-4 py-3">
							<div class="flex items-center justify-between gap-3">
								<div>
									<p class="text-sm font-semibold">Acme Workspace</p>
									<p class="text-xs text-muted-fg">{{ previewMode }} menu preview</p>
								</div>
								<span class="size-2 rounded-full bg-success" aria-hidden="true"></span>
							</div>
						</div>
						<nav class="grid gap-1 p-2" aria-label="Preview navigation">
							<PreviewMenuItem
								v-for="item in navItems"
								:key="item.id"
								:item="item"
								:selected-id="selectedItemId"
								@select="selectItem"
							/>
						</nav>
					</div>

					<div class="mt-4 rounded-lg border border-border bg-background p-4">
						<p class="text-sm font-semibold">Draft payload</p>
						<pre class="mt-3 max-h-64 overflow-auto rounded-md bg-secondary p-3 text-xs leading-5 text-muted-fg">{{ JSON.stringify(publishPayload, null, 2) }}</pre>
					</div>
				</aside>
			</section>
		</main>

		<DomDialog
			v-model="publishDialogOpen"
			title="Publish navigation draft?"
			description="Create a versioned menu that your app shell can fetch and cache."
		>
			<div class="space-y-3 text-sm leading-6 text-muted-fg">
				<p>
					This draft contains <strong class="text-fg">{{ changedIds.length }}</strong> changed item{{ changedIds.length === 1 ? '' : 's' }} across
					<strong class="text-fg">{{ totalItemCount }}</strong> navigation records.
				</p>
				<div class="rounded-lg border border-border bg-secondary/50 p-3">
					<p class="font-semibold text-fg">Backend checks to run</p>
					<ul class="mt-2 list-disc space-y-1 pl-5">
						<li>Validate every route and parent ID.</li>
						<li>Confirm feature flags exist in the current workspace.</li>
						<li>Recompute role visibility before writing the published version.</li>
					</ul>
				</div>
			</div>
			<template #footer>
				<DomButton variant="secondary" size="sm" @click="publishDialogOpen = false">Keep editing</DomButton>
				<DomButton size="sm" @click="publishDraft">Publish version</DomButton>
			</template>
		</DomDialog>
	</div>
</template>

Local components

Copy the preview helpers

<script setup>
const props = defineProps({
	tone: {
		type: String,
		default: 'neutral',
	},
});

const toneClasses = {
	success: 'bg-success/10 text-success ring-success/25',
	warning: 'bg-warning/10 text-warning ring-warning/25',
	destructive: 'bg-destructive/10 text-destructive ring-destructive/25',
	primary: 'bg-primary/10 text-primary ring-primary/25',
	neutral: 'bg-secondary text-muted-fg ring-border',
};
</script>

<template>
	<span
		class="inline-flex w-fit items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ring-1"
		:class="toneClasses[props.tone] || toneClasses.neutral"
	>
		<span class="size-1.5 rounded-full bg-current" aria-hidden="true"></span>
		<slot />
	</span>
</template>
<script setup>
import NavigationStatusPill from './NavigationStatusPill.vue';

defineOptions({
	name: 'PreviewMenuItem',
});

defineProps({
	item: {
		type: Object,
		required: true,
	},
	selectedId: {
		type: String,
		default: '',
	},
	depth: {
		type: Number,
		default: 0,
	},
});

defineEmits(['select']);
</script>

<template>
	<div class="grid gap-1">
		<button
			type="button"
			class="group flex min-h-11 w-full items-center justify-between gap-3 rounded-md px-3 py-2 text-left text-sm outline-none transition hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring/50"
			:class="selectedId === item.id ? 'bg-primary/10 text-fg' : 'text-muted-fg'"
			:style="{ paddingLeft: `${0.75 + depth * 0.8}rem` }"
			@click="$emit('select', item.id)"
		>
			<span class="flex min-w-0 items-center gap-2">
				<svg viewBox="0 0 24 24" class="size-4 shrink-0" fill="none" aria-hidden="true">
					<path :d="item.icon" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
				</svg>
				<span class="min-w-0">
					<span class="block truncate font-medium text-fg">{{ item.label }}</span>
					<span class="block truncate text-xs text-muted-fg">{{ item.url }}</span>
				</span>
			</span>
			<NavigationStatusPill v-if="item.visibility !== 'everyone'" :tone="item.visibility === 'admins' ? 'warning' : 'primary'">
				{{ item.visibilityLabel }}
			</NavigationStatusPill>
		</button>

		<div v-if="item.children?.length" class="grid gap-1">
			<PreviewMenuItem
				v-for="child in item.children"
				:key="child.id"
				:item="child"
				:selected-id="selectedId"
				:depth="depth + 1"
				@select="$emit('select', $event)"
			/>
		</div>
	</div>
</template>

Integration

How to use this block

Use this block when builders need to manage app navigation from a product surface instead of a hard-coded route file. It combines hierarchy editing, destination details, feature flags, audience rules, preview modes, and a publish confirmation workflow.

  • Load the tree from your navigation API and persist reorder events from DomTreeView as parent and sort-order updates.
  • Keep route availability, feature flag state, and permission checks on the server; the client should only preview the rules.
  • Map visibility options to product roles, plan gates, workspaces, regions, or custom policy expressions in your backend.
  • Publish changes as a versioned navigation draft so customers can preview, roll back, and audit who changed important links.
  • Use the live preview as a visual contract for desktop sidebar, mobile drawer, and customer-portal menus.

Data

Recommended navigation payload

{
	draftId: 'nav_draft_0924',
	workspaceId: 'wrk_2048',
	version: 17,
	status: 'draft',
	previewMode: 'desktop',
	items: [
		{
			id: 'overview',
			parentId: null,
			label: 'Overview',
			url: '/app',
			visibility: 'everyone',
			audience: ['all-users'],
			featureFlag: null,
			requiresAuth: true,
			sortOrder: 10
		}
	],
	changes: {
		added: ['automation'],
		updated: ['reports'],
		reordered: ['billing']
	}
}

Customization

Implementation notes

Tree persistence

Use stable item IDs and store parent IDs plus sibling sort orders. Treat drag/drop as intent, then validate the move server-side.

Audience rules

Do not rely on menu hiding for authorization. Hidden links improve UX, but protected routes and APIs still need policy enforcement.

Future updates

Useful follow-ups include icon pickers, scheduled publishes, localization, route health checks, bulk audience edits, and reusable navigation preview rows.