Blocks
App Navigation Builder Block
Working APIA complete navigation operations section for editing hierarchy, checking real routes and policies, previewing audiences, and publishing immutable app-shell versions.
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.
Built with
DomAlertVisualDomAppShellMobileDomAppTopBarMobileDomAvatarVisualDomButtonComponentsDomDialogComponentsDomDrawerComponentsDomIconButtonComponentsDomIconSelectorFormsDomProfileMenuComponentsDomSelectFormsDomSkeletonVisualDomStatusPillVisualDomTagComboboxFormsDomTextInputFormsDomTextareaInputFormsDomToggleForms<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import {
DomAlert,
DomAppShell,
DomAppTopBar,
DomAvatar,
DomButton,
DomDialog,
DomDrawer,
DomIconButton,
DomIconSelector,
DomProfileMenu,
DomSelect,
DomSkeleton,
DomStatusPill,
DomTagCombobox,
DomTextInput,
DomTextareaInput,
DomToggle,
} from '@getdom/studio/vue';
import NavigationInspector from '../components/NavigationInspector.vue';
import NavigationTreePanel from '../components/NavigationTreePanel.vue';
import PreviewMenuItem from '../components/PreviewMenuItem.vue';
const menuIcon = 'M4 7h16M4 12h16M4 17h16';
const inspectIcon = 'M12 5v14M5 12h14';
const closeIcon = 'M6 6l12 12M18 6 6 18';
const bootstrap = ref(null);
const draft = ref(null);
const treeItems = ref([]);
const validation = ref({ issues: [], blockerCount: 0, warningCount: 0, status: 'ready', checks: [] });
const preview = ref({ items: [], meta: { visibleCount: 0, hiddenCount: 0 } });
const selectedItemId = ref('ops-console');
const editorDraft = ref(null);
const previewAudience = ref('member');
const previewDevice = ref('desktop');
const loading = ref(true);
const previewLoading = ref(false);
const saving = ref(false);
const validating = ref(false);
const publishing = ref(false);
const reordering = ref(false);
const message = ref('');
const errorMessage = ref('');
const navigatorOpen = ref(false);
const inspectorOpen = ref(false);
const previewMobileNavOpen = ref(false);
const addDialogOpen = ref(false);
const archiveDialogOpen = ref(false);
const publishDialogOpen = ref(false);
const publishReceipt = ref(null);
const releaseNote = ref('Fix operations destination and refresh audience navigation.');
const archiveReason = ref('This destination is no longer part of the primary application journey.');
const archiveTargetLabel = ref('');
const addDraft = ref(createNewItemDraft());
const selectedItem = computed(getSelectedItem);
const selectedPath = computed(getSelectedPath);
const selectedIssues = computed(getSelectedIssues);
const editorDirty = computed(getEditorDirty);
const selectedHasChildren = computed(getSelectedHasChildren);
const validationTone = computed(getValidationTone);
const validationLabel = computed(getValidationLabel);
const previewFrameClasses = computed(getPreviewFrameClasses);
const parentOptions = computed(getParentOptions);
const previewSelectedItem = computed(getPreviewSelectedItem);
const selectedVisibleInPreview = computed(getSelectedVisibleInPreview);
watch(selectedItem, synchronizeEditorDraft, { immediate: true });
watch([previewAudience, previewDevice], refreshPreviewFromControls);
onMounted(loadWorkspace);
/**
* Return the selected item from the nested navigation tree.
*
* @returns {Record<string, unknown>|null} Selected navigation item.
*/
function getSelectedItem() {
return findItem(treeItems.value, selectedItemId.value);
}
/**
* Return the selected item breadcrumb path.
*
* @returns {string} Human-readable navigation path.
*/
function getSelectedPath() {
return itemPath(treeItems.value, selectedItemId.value).map(getItemLabel).join(' / ');
}
/**
* Return server-owned issues associated with the selected navigation item.
*
* @returns {Array<Record<string, unknown>>} Selected item issues.
*/
function getSelectedIssues() {
return (validation.value?.issues || []).filter(isSelectedIssue);
}
/**
* Compare the editable form with the latest server-backed item state.
*
* @returns {boolean} True when the inspector contains unsaved edits.
*/
function getEditorDirty() {
if (!selectedItem.value || !editorDraft.value) return false;
return JSON.stringify(editableSnapshot(selectedItem.value)) !== JSON.stringify(editableSnapshot(editorDraft.value));
}
/**
* Check whether the selected item owns active child destinations.
*
* @returns {boolean} True when the selected item has children.
*/
function getSelectedHasChildren() {
return Boolean(selectedItem.value?.children?.length);
}
/**
* Map the current validation report to a DOM Studio status tone.
*
* @returns {string} Status tone.
*/
function getValidationTone() {
if (validation.value?.blockerCount) return 'danger';
if (validation.value?.warningCount) return 'warning';
return 'success';
}
/**
* Return the concise status label shown in the editor toolbar.
*
* @returns {string} Validation status label.
*/
function getValidationLabel() {
if (validation.value?.blockerCount) return `${validation.value.blockerCount} blocker${validation.value.blockerCount === 1 ? '' : 's'}`;
if (draft.value?.status === 'published') return `Version ${draft.value.publishedVersion} live`;
return 'Ready to publish';
}
/**
* Size the app-shell preview to its selected target without overflowing the iframe.
*
* @returns {Array<string>} Preview frame classes.
*/
function getPreviewFrameClasses() {
const base = 'relative mx-auto h-full min-h-[32rem] transition-[width,max-width] duration-200';
if (previewDevice.value === 'mobile') return [base, 'w-full max-w-[24rem]'];
if (previewDevice.value === 'compact') return [base, 'w-full max-w-3xl'];
return [base, 'w-full max-w-5xl'];
}
/**
* Build valid parent options for the add-item dialog.
*
* @returns {Array<Record<string, string>>} Parent select options.
*/
function getParentOptions() {
const options = [{ value: 'root', label: 'Top level', description: 'Place beside the primary destinations.' }];
for (const item of flattenItems(treeItems.value)) {
if (itemPath(treeItems.value, item.id).length >= 3) continue;
options.push({ value: item.id, label: item.label, description: item.url });
}
return options;
}
/**
* Return the selected item when it survives the current server preview policy.
*
* @returns {Record<string, unknown>|null} Visible preview item.
*/
function getPreviewSelectedItem() {
return findItem(preview.value.items || [], selectedItemId.value) || flattenItems(preview.value.items || [])[0] || null;
}
/**
* Check whether the editor selection is visible to the active preview audience.
*
* @returns {boolean} True when the selected item is present in the preview tree.
*/
function getSelectedVisibleInPreview() {
return Boolean(findItem(preview.value.items || [], selectedItemId.value));
}
/**
* Synchronize the inspector with a newly selected server-backed item.
*
* @param {Record<string, unknown>|null} item Selected navigation item.
* @returns {void}
*/
function synchronizeEditorDraft(item) {
editorDraft.value = item ? editableSnapshot(item) : null;
}
/**
* Refresh the server-filtered preview after an audience or device change.
*
* @returns {Promise<void>} Resolves after the preview refresh completes.
*/
async function refreshPreviewFromControls() {
previewMobileNavOpen.value = false;
await loadPreview();
}
/**
* Load the complete navigation workspace and its initial policy preview.
*
* @returns {Promise<void>} Resolves after the editor is ready.
*/
async function loadWorkspace() {
loading.value = true;
errorMessage.value = '';
try {
applyResponsivePreviewDefault();
const result = await apiRequest('/api/block-demos/app-navigation-builder/bootstrap');
bootstrap.value = result;
draft.value = result.draft;
treeItems.value = result.items;
validation.value = result.validation;
if (!findItem(result.items, selectedItemId.value)) selectedItemId.value = result.items[0]?.id || '';
await loadPreview();
} catch (error) {
errorMessage.value = error.message;
} finally {
loading.value = false;
}
}
/**
* Choose a useful initial app-shell target for the iframe or mobile viewport.
*
* @returns {void}
*/
function applyResponsivePreviewDefault() {
if (typeof window === 'undefined') return;
if (window.matchMedia('(max-width: 639px)').matches) {
previewDevice.value = 'mobile';
return;
}
if (window.matchMedia('(max-width: 1023px)').matches) previewDevice.value = 'compact';
}
/**
* Load the server-owned menu visible to the selected preview persona.
*
* @returns {Promise<void>} Resolves after the preview is updated.
*/
async function loadPreview() {
if (!bootstrap.value) return;
previewLoading.value = true;
try {
const query = new URLSearchParams({
audience: previewAudience.value,
device: previewDevice.value,
});
preview.value = await apiRequest(`/api/block-demos/app-navigation-builder/preview?${query}`);
} catch (error) {
errorMessage.value = error.message;
} finally {
previewLoading.value = false;
}
}
/**
* Select one navigation item and expose its inspector on constrained screens.
*
* @param {string} itemId Stable navigation item identifier.
* @returns {void}
*/
function selectItem(itemId) {
selectedItemId.value = String(itemId);
navigatorOpen.value = false;
if (typeof window !== 'undefined' && window.matchMedia('(max-width: 1279px)').matches) inspectorOpen.value = true;
}
/**
* Save the selected item through the optimistic API contract.
*
* @returns {Promise<void>} Resolves after the server accepts or rejects the edit.
*/
async function saveSelectedItem() {
if (!editorDraft.value || !draft.value) return;
saving.value = true;
clearFeedback();
try {
const result = await apiRequest(`/api/block-demos/app-navigation-builder/items/${encodeURIComponent(editorDraft.value.id)}`, {
method: 'PATCH',
body: { ...editableSnapshot(editorDraft.value), revision: draft.value.revision },
});
applyMutation(result);
message.value = result.message;
await loadPreview();
} catch (error) {
handleApiError(error);
} finally {
saving.value = false;
}
}
/**
* Persist a tree reorder produced by pointer drag or keyboard commands.
*
* @param {Array<Record<string, unknown>>} items Reordered nested tree.
* @returns {Promise<void>} Resolves after the server persists the hierarchy.
*/
async function persistReorder(items) {
if (!draft.value || reordering.value) return;
const previousItems = treeItems.value;
treeItems.value = items;
reordering.value = true;
clearFeedback();
try {
const result = await apiRequest('/api/block-demos/app-navigation-builder/reorder', {
method: 'POST',
body: { items: compactTree(items), revision: draft.value.revision },
});
applyMutation(result);
message.value = result.message;
await loadPreview();
} catch (error) {
treeItems.value = previousItems;
handleApiError(error);
} finally {
reordering.value = false;
}
}
/**
* Open the add-item flow with a useful parent default.
*
* @returns {void}
*/
function openAddDialog() {
addDraft.value = createNewItemDraft(selectedItem.value?.id || 'root');
addDialogOpen.value = true;
navigatorOpen.value = false;
}
/**
* Add a destination to the active navigation draft.
*
* @returns {Promise<void>} Resolves after the server creates the item.
*/
async function createItem() {
if (!draft.value) return;
saving.value = true;
clearFeedback();
try {
const result = await apiRequest('/api/block-demos/app-navigation-builder/items', {
method: 'POST',
body: { ...addDraft.value, revision: draft.value.revision },
});
applyMutation(result);
selectedItemId.value = result.item.id;
addDialogOpen.value = false;
message.value = result.message;
await loadPreview();
} catch (error) {
handleApiError(error);
} finally {
saving.value = false;
}
}
/**
* Open the archive confirmation for the selected leaf item.
*
* @returns {void}
*/
function openArchiveDialog() {
archiveTargetLabel.value = selectedItem.value?.label || 'This item';
archiveReason.value = 'This destination is no longer part of the primary application journey.';
archiveDialogOpen.value = true;
}
/**
* Archive the selected leaf item with an audit reason.
*
* @returns {Promise<void>} Resolves after the server archives the item.
*/
async function archiveSelectedItem() {
if (!selectedItem.value || !draft.value) return;
saving.value = true;
clearFeedback();
try {
const result = await apiRequest(`/api/block-demos/app-navigation-builder/items/${encodeURIComponent(selectedItem.value.id)}/archive`, {
method: 'POST',
body: { revision: draft.value.revision, reason: archiveReason.value },
});
applyMutation(result);
selectedItemId.value = treeItems.value[0]?.id || '';
archiveDialogOpen.value = false;
inspectorOpen.value = false;
message.value = result.message;
await loadPreview();
} catch (error) {
handleApiError(error);
} finally {
saving.value = false;
}
}
/**
* Run route, audience, feature-flag, and hierarchy checks on the server.
*
* @returns {Promise<boolean>} True when the validation request succeeds.
*/
async function runValidation() {
if (!draft.value) return false;
validating.value = true;
clearFeedback();
try {
const result = await apiRequest('/api/block-demos/app-navigation-builder/validate', {
method: 'POST',
body: { revision: draft.value.revision },
});
validation.value = result.validation;
draft.value = result.draft;
message.value = result.message;
return true;
} catch (error) {
handleApiError(error);
return false;
} finally {
validating.value = false;
}
}
/**
* Refresh validation and open the publish review dialog.
*
* @returns {Promise<void>} Resolves after the review dialog opens.
*/
async function openPublishDialog() {
publishReceipt.value = null;
const loaded = await runValidation();
if (loaded) publishDialogOpen.value = true;
}
/**
* Publish an immutable navigation version after all server checks pass.
*
* @returns {Promise<void>} Resolves after publish succeeds or fails.
*/
async function publishDraft() {
if (!draft.value) return;
publishing.value = true;
clearFeedback();
try {
const result = await apiRequest('/api/block-demos/app-navigation-builder/publish', {
method: 'POST',
body: { revision: draft.value.revision, releaseNote: releaseNote.value },
});
applyMutation(result);
publishReceipt.value = result.receipt;
message.value = result.message;
} catch (error) {
handleApiError(error);
} finally {
publishing.value = false;
}
}
/**
* Apply a successful API mutation as the new client source of truth.
*
* @param {Record<string, unknown>} result Mutation response.
* @returns {void}
*/
function applyMutation(result) {
if (result.draft) draft.value = result.draft;
if (result.items) treeItems.value = result.items;
if (result.validation) validation.value = result.validation;
const nextSelected = findItem(treeItems.value, selectedItemId.value);
if (!nextSelected) selectedItemId.value = treeItems.value[0]?.id || '';
synchronizeEditorDraft(findItem(treeItems.value, selectedItemId.value));
}
/**
* Keep the inspector drawer open after teleported DOM Studio controls commit.
*
* @returns {void}
*/
function keepInspectorDrawerOpen() {
inspectorOpen.value = true;
setTimeout(() => { inspectorOpen.value = true; }, 24);
}
/**
* Toggle the mobile app-shell menu inside the live preview.
*
* @returns {void}
*/
function togglePreviewNavigation() {
previewMobileNavOpen.value = !previewMobileNavOpen.value;
}
/**
* Close the publish dialog and clear any previous receipt.
*
* @returns {void}
*/
function closePublishDialog() {
publishDialogOpen.value = false;
publishReceipt.value = null;
}
/**
* Clear transient success and error feedback before a new request.
*
* @returns {void}
*/
function clearFeedback() {
message.value = '';
errorMessage.value = '';
}
/**
* Surface an API error and synchronize conflict state when available.
*
* @param {Error & { payload?: Record<string, unknown> }} error Request error.
* @returns {void}
*/
function handleApiError(error) {
errorMessage.value = error.message;
if (error.payload?.draft) draft.value = error.payload.draft;
if (error.payload?.items) treeItems.value = error.payload.items;
}
/**
* Request one demo API route and convert non-success responses into rich errors.
*
* @param {string} path API route.
* @param {{ method?: string, body?: Record<string, unknown> }} [options={}] Request options.
* @returns {Promise<Record<string, unknown>>} Parsed API payload.
*/
async function apiRequest(path, options = {}) {
const response = await fetch(path, {
method: options.method || 'GET',
headers: options.body ? { 'Content-Type': 'application/json' } : undefined,
body: options.body ? JSON.stringify(options.body) : undefined,
});
const payload = await response.json();
if (!response.ok) {
const error = new Error(payload.message || 'The navigation request could not be completed.');
error.payload = payload;
throw error;
}
return payload;
}
/**
* Return the first item matching an identifier in a nested tree.
*
* @param {Array<Record<string, unknown>>} items Nested items.
* @param {string} itemId Stable identifier.
* @returns {Record<string, unknown>|null} Matching item or null.
*/
function findItem(items, itemId) {
for (const item of items || []) {
if (String(item.id) === String(itemId)) return item;
const child = findItem(item.children || [], itemId);
if (child) return child;
}
return null;
}
/**
* Return the ancestor path for one nested item.
*
* @param {Array<Record<string, unknown>>} items Nested items.
* @param {string} itemId Stable identifier.
* @param {Array<Record<string, unknown>>} [parents=[]] Parent accumulator.
* @returns {Array<Record<string, unknown>>} Ancestor and selected items.
*/
function itemPath(items, itemId, parents = []) {
for (const item of items || []) {
const nextParents = [...parents, item];
if (String(item.id) === String(itemId)) return nextParents;
const childPath = itemPath(item.children || [], itemId, nextParents);
if (childPath.length) return childPath;
}
return [];
}
/**
* Flatten a nested tree while preserving its visible order.
*
* @param {Array<Record<string, unknown>>} items Nested items.
* @returns {Array<Record<string, unknown>>} Flat item list.
*/
function flattenItems(items) {
const rows = [];
for (const item of items || []) rows.push(item, ...flattenItems(item.children || []));
return rows;
}
/**
* Return a serializable tree containing only identifiers and children.
*
* @param {Array<Record<string, unknown>>} items Nested tree items.
* @returns {Array<Record<string, unknown>>} Compact reorder payload.
*/
function compactTree(items) {
return (items || []).map(compactTreeItem);
}
/**
* Serialize one item for the reorder API.
*
* @param {Record<string, unknown>} item Tree item.
* @returns {Record<string, unknown>} Compact tree item.
*/
function compactTreeItem(item) {
return { id: item.id, children: compactTree(item.children || []) };
}
/**
* Return only fields editable through the item inspector.
*
* @param {Record<string, unknown>} item Navigation item.
* @returns {Record<string, unknown>} Editable item snapshot.
*/
function editableSnapshot(item) {
return {
id: item.id,
label: item.label || '',
url: item.url || '',
description: item.description || '',
icon: item.icon || '',
visibility: item.visibility || 'everyone',
audience: [...(item.audience || [])],
featureFlag: item.featureFlag || '',
requiresAuth: item.requiresAuth !== false,
openInNewTab: item.openInNewTab === true,
};
}
/**
* Create a fresh add-item form model.
*
* @param {string} [parentId='root'] Default parent identifier.
* @returns {Record<string, unknown>} Add-item draft.
*/
function createNewItemDraft(parentId = 'root') {
return {
parentId,
label: 'Help center',
url: '/app/help',
description: 'Guides, product updates, and contact options.',
visibility: 'everyone',
audience: ['all-users'],
featureFlag: '',
requiresAuth: true,
openInNewTab: false,
icon: 'M5 5h14v14H5V5Zm3 4h8M8 12h8M8 15h5',
};
}
/**
* Return a navigation item label for path rendering.
*
* @param {Record<string, unknown>} item Navigation item.
* @returns {string} Item label.
*/
function getItemLabel(item) {
return String(item.label || 'Untitled');
}
/**
* Test whether one issue belongs to the selected navigation item.
*
* @param {Record<string, unknown>} issue Validation issue.
* @returns {boolean} True when the issue belongs to the selected item.
*/
function isSelectedIssue(issue) {
return String(issue.itemId) === String(selectedItemId.value);
}
/**
* Format a timestamp for compact editor chrome.
*
* @param {string|null|undefined} value ISO timestamp.
* @returns {string} Localized timestamp label.
*/
function formatTimestamp(value) {
if (!value) return 'Not yet';
return new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(value));
}
</script>
<template>
<div class="flex h-dvh min-h-[38rem] w-full flex-col overflow-hidden border border-border bg-canvas text-canvas-fg">
<header class="flex h-14 shrink-0 items-center gap-3 border-b border-border bg-canvas px-3 sm:px-4">
<DomIconButton
class="md:hidden"
label="Open navigation hierarchy"
size="sm"
:icon="menuIcon"
@click="navigatorOpen = true"
/>
<div class="grid size-8 shrink-0 place-items-center rounded-lg bg-primary text-sm font-bold text-primary-fg">N</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold tracking-tight">Navigation studio</p>
<p class="hidden truncate text-[11px] text-muted-fg sm:block">Northstar · Primary app · Production</p>
</div>
<div class="hidden sm:block">
<DomStatusPill :tone="validationTone" :label="validationLabel" size="sm" />
</div>
<div class="hidden md:block">
<DomButton variant="secondary" size="sm" :loading="validating" @click="runValidation">Run checks</DomButton>
</div>
<DomButton size="sm" @click="openPublishDialog">Publish</DomButton>
<div class="hidden lg:block">
<DomProfileMenu
name="Maya Chen"
email="maya@northstar.tools"
initials="MC"
:show-email="false"
:theme-control="false"
/>
</div>
</header>
<div v-if="errorMessage || message" class="shrink-0 border-b border-border px-3 py-2 sm:px-4">
<DomAlert
:tone="errorMessage ? 'danger' : 'success'"
variant="toast"
:title="errorMessage ? 'Navigation update failed' : 'Navigation updated'"
:description="errorMessage || message"
dismissible
@dismiss="clearFeedback"
/>
</div>
<div v-if="loading" class="grid min-h-0 flex-1 gap-3 p-4 md:grid-cols-[17rem_minmax(0,1fr)] xl:grid-cols-[17rem_minmax(0,1fr)_21rem]">
<DomSkeleton class="h-full min-h-80" />
<DomSkeleton class="h-full min-h-80" />
<DomSkeleton class="hidden h-full min-h-80 xl:block" />
</div>
<main
v-else
class="grid min-h-0 flex-1 md:grid-cols-[17rem_minmax(0,1fr)] xl:grid-cols-[17rem_minmax(0,1fr)_21rem]"
>
<aside class="hidden min-h-0 border-r border-border md:block">
<NavigationTreePanel
:items="treeItems"
:selected-id="selectedItemId"
:validation="validation"
:reordering="reordering"
@add="openAddDialog"
@select="selectItem"
@reorder="persistReorder"
/>
</aside>
<section class="flex min-h-0 min-w-0 flex-col bg-secondary/35">
<div class="flex min-h-14 shrink-0 items-center gap-2 overflow-x-auto border-b border-border bg-canvas px-3 sm:px-4">
<span class="hidden shrink-0 text-xs font-semibold uppercase tracking-[0.12em] text-muted-fg lg:inline">Preview as</span>
<DomSelect
v-model="previewAudience"
class="min-w-0 flex-1 sm:flex-none"
aria-label="Preview audience"
:options="bootstrap.previewAudienceOptions"
width="min-w-[12rem]"
/>
<DomSelect
v-model="previewDevice"
class="min-w-0 flex-1 sm:flex-none"
aria-label="Preview device"
:options="bootstrap.deviceOptions"
width="min-w-[11rem]"
/>
<div class="hidden min-w-2 flex-1 sm:block"></div>
<span class="hidden shrink-0 text-xs text-muted-fg sm:inline">
{{ preview.meta.visibleCount }} visible · {{ preview.meta.hiddenCount }} filtered
</span>
<div class="hidden sm:block xl:hidden">
<DomIconButton
label="Edit selected destination"
size="sm"
:icon="inspectIcon"
@click="inspectorOpen = true"
/>
</div>
</div>
<div class="min-h-0 flex-1 overflow-auto p-3 sm:p-5">
<div :class="previewFrameClasses">
<DomAppShell variant="card" class="border border-border shadow-xl shadow-black/10">
<template #top>
<DomAppTopBar
title="Northstar"
:subtitle="`${previewAudience.replace('-', ' ')} preview · ${bootstrap.workspace.domain}`"
>
<template v-if="previewDevice === 'mobile'" #leading>
<DomIconButton label="Open preview navigation" size="sm" :icon="menuIcon" @click="togglePreviewNavigation" />
</template>
<template #trailing>
<DomAvatar name="Ari Gupta" initials="AG" size="sm" />
</template>
</DomAppTopBar>
</template>
<div class="flex h-full min-h-0">
<aside
v-if="previewDevice !== 'mobile'"
class="min-h-0 shrink-0 overflow-y-auto border-r border-border bg-canvas p-2"
:class="previewDevice === 'compact' ? 'w-44' : 'w-56'"
>
<p class="px-2 pb-2 pt-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-fg">Workspace</p>
<nav class="grid gap-0.5" aria-label="Server-filtered application preview">
<PreviewMenuItem
v-for="item in preview.items"
:key="item.id"
:item="item"
:selected-id="selectedItemId"
:compact="previewDevice === 'compact'"
@select="selectItem"
/>
</nav>
</aside>
<div class="min-w-0 flex-1 overflow-y-auto bg-canvas">
<div class="mx-auto max-w-4xl px-4 py-5 sm:px-6 sm:py-7">
<DomAlert
v-if="!selectedVisibleInPreview"
class="mb-5"
tone="info"
variant="soft"
title="Audience preview applied"
:description="`${selectedItem?.label || 'This destination'} is hidden for this audience. Showing the first available destination instead.`"
/>
<div class="flex flex-col gap-4 border-b border-border pb-5 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-muted-fg">{{ previewSelectedItem?.url || '/app' }}</p>
<h1 class="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">{{ previewSelectedItem?.label || 'Workspace overview' }}</h1>
<p class="mt-2 max-w-xl text-sm leading-6 text-muted-fg">{{ previewSelectedItem?.description || 'Your workspace activity and next actions.' }}</p>
</div>
<DomButton size="sm">Create project</DomButton>
</div>
<div class="grid gap-6 py-6">
<section>
<div class="flex items-center justify-between gap-3">
<h2 class="text-base font-semibold">Current work</h2>
<DomButton variant="ghost" size="xs">View all</DomButton>
</div>
<div class="mt-3 divide-y divide-border border-y border-border">
<div class="flex items-center gap-3 py-3">
<DomAvatar name="Atlas rollout" initials="AR" size="sm" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">Atlas rollout</p>
<p class="text-xs text-muted-fg">Design review · Today</p>
</div>
<DomStatusPill tone="success" label="On track" size="sm" />
</div>
<div class="flex items-center gap-3 py-3">
<DomAvatar name="Beacon migration" initials="BM" size="sm" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">Beacon migration</p>
<p class="text-xs text-muted-fg">Engineering · Tomorrow</p>
</div>
<DomStatusPill tone="warning" label="At risk" size="sm" />
</div>
<div class="flex items-center gap-3 py-3">
<DomAvatar name="Comet launch" initials="CL" size="sm" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">Comet launch</p>
<p class="text-xs text-muted-fg">Content · Friday</p>
</div>
<DomStatusPill tone="primary" label="Review" size="sm" />
</div>
</div>
</section>
<aside class="border-t border-border pt-5">
<div class="flex items-end justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-muted-fg">This week</p>
<p class="mt-2 text-3xl font-semibold tracking-tight">82%</p>
</div>
<p class="max-w-44 text-right text-xs leading-5 text-muted-fg">Milestones completed on time</p>
</div>
<div class="mt-4 grid grid-cols-3 divide-x divide-border border-y border-border py-3 text-center text-xs">
<div><strong class="block text-base">12</strong><span class="text-muted-fg">Projects</span></div>
<div><strong class="block text-base">4</strong><span class="text-muted-fg">Reviews</span></div>
<div><strong class="block text-base">18</strong><span class="text-muted-fg">Online</span></div>
</div>
</aside>
</div>
</div>
</div>
</div>
<template #overlay>
<div v-if="previewDevice === 'mobile' && previewMobileNavOpen" class="pointer-events-auto absolute inset-0 flex bg-black/35">
<aside class="flex h-full w-[82%] max-w-72 flex-col border-r border-border bg-canvas shadow-xl">
<div class="flex items-center justify-between border-b border-border px-3 py-3">
<div>
<p class="text-sm font-semibold">Northstar</p>
<p class="text-xs text-muted-fg">Workspace navigation</p>
</div>
<DomIconButton label="Close preview navigation" size="sm" :icon="closeIcon" @click="togglePreviewNavigation" />
</div>
<nav class="min-h-0 flex-1 overflow-y-auto p-2" aria-label="Mobile application preview">
<PreviewMenuItem
v-for="item in preview.items"
:key="item.id"
:item="item"
:selected-id="selectedItemId"
@select="selectItem"
/>
</nav>
</aside>
<button type="button" class="min-w-0 flex-1" aria-label="Close preview navigation" @click="togglePreviewNavigation"></button>
</div>
</template>
</DomAppShell>
</div>
</div>
<footer class="flex min-h-13 shrink-0 items-center justify-between gap-3 border-t border-border bg-canvas px-3 xl:hidden">
<div class="min-w-0">
<p class="truncate text-sm font-medium">{{ selectedItem?.label }}</p>
<p class="truncate text-xs text-muted-fg">{{ selectedIssues.length ? `${selectedIssues.length} issue${selectedIssues.length === 1 ? '' : 's'}` : 'Ready to publish' }}</p>
</div>
<DomButton variant="secondary" size="sm" @click="inspectorOpen = true">Edit destination</DomButton>
</footer>
</section>
<aside class="hidden min-h-0 border-l border-border xl:block">
<NavigationInspector
v-if="editorDraft"
v-model="editorDraft"
:visibility-options="bootstrap.visibilityOptions"
:audience-options="bootstrap.audienceOptions"
:icon-options="bootstrap.iconOptions"
:issues="selectedIssues"
:path="selectedPath"
:saving="saving"
:dirty="editorDirty"
:has-children="selectedHasChildren"
@save="saveSelectedItem"
@archive="openArchiveDialog"
/>
</aside>
</main>
<DomDrawer v-model="navigatorOpen" side="left" width="min(22rem, 92vw)" title="Navigation hierarchy" :static="true">
<NavigationTreePanel
:items="treeItems"
:selected-id="selectedItemId"
:validation="validation"
:reordering="reordering"
@add="openAddDialog"
@select="selectItem"
@reorder="persistReorder"
/>
</DomDrawer>
<DomDrawer v-model="inspectorOpen" side="right" width="min(25rem, 94vw)" title="Destination inspector" :static="true">
<NavigationInspector
v-if="editorDraft"
v-model="editorDraft"
:visibility-options="bootstrap?.visibilityOptions || []"
:audience-options="bootstrap?.audienceOptions || []"
:icon-options="bootstrap?.iconOptions || []"
:issues="selectedIssues"
:path="selectedPath"
:saving="saving"
:dirty="editorDirty"
:has-children="selectedHasChildren"
@save="saveSelectedItem"
@archive="openArchiveDialog"
@floating-interaction="keepInspectorDrawerOpen"
/>
</DomDrawer>
<DomDialog
v-model="addDialogOpen"
title="Add navigation destination"
description="Create a real draft item, then validate it against the application router."
>
<div class="grid gap-4 sm:grid-cols-2">
<DomTextInput v-model="addDraft.label" label="Menu label" placeholder="Help center" />
<DomTextInput v-model="addDraft.url" label="Destination" placeholder="/app/help" />
<DomSelect
v-model="addDraft.parentId"
label="Parent"
:options="parentOptions"
width="min-w-[18rem]"
/>
<DomSelect
v-model="addDraft.visibility"
label="Visibility"
:options="bootstrap?.visibilityOptions || []"
width="min-w-[18rem]"
/>
<div class="sm:col-span-2">
<DomTextareaInput v-model="addDraft.description" label="Internal description" :rows="3" />
</div>
<div class="sm:col-span-2">
<DomIconSelector
v-model="addDraft.icon"
label="Navigation icon"
kind="custom"
:items="bootstrap?.iconOptions || []"
:include-built-ins="false"
:clearable="false"
/>
</div>
<div v-if="addDraft.visibility === 'roles' || addDraft.visibility === 'admins'" class="sm:col-span-2">
<DomTagCombobox
v-model="addDraft.audience"
label="Allowed audiences"
:options="bootstrap?.audienceOptions || []"
/>
</div>
<DomToggle v-model="addDraft.requiresAuth" label="Requires sign in" />
<DomToggle v-model="addDraft.openInNewTab" label="Open in a new tab" />
</div>
<template #footer>
<DomButton variant="secondary" size="sm" @click="addDialogOpen = false">Cancel</DomButton>
<DomButton size="sm" :loading="saving" @click="createItem">Add destination</DomButton>
</template>
</DomDialog>
<DomDialog
v-model="archiveDialogOpen"
title="Archive navigation item?"
:description="`${archiveTargetLabel} will leave the draft tree and every audience preview.`"
>
<DomTextareaInput v-model="archiveReason" label="Archive reason" :rows="3" />
<template #footer>
<DomButton variant="secondary" size="sm" @click="archiveDialogOpen = false">Keep item</DomButton>
<DomButton variant="danger" size="sm" :loading="saving" @click="archiveSelectedItem">Archive item</DomButton>
</template>
</DomDialog>
<DomDialog
v-model="publishDialogOpen"
:title="publishReceipt ? `Version ${publishReceipt.version} is live` : 'Review navigation publish'"
:description="publishReceipt ? 'The API returned an immutable deployment receipt.' : 'Publish the validated menu used by the production app shell.'"
>
<div v-if="publishReceipt" class="space-y-4">
<DomAlert tone="success" title="Navigation published" :description="`${publishReceipt.itemCount} destinations are live in production.`" />
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 rounded-lg border border-border bg-secondary/35 p-4 text-sm">
<div><dt class="text-xs text-muted-fg">Receipt</dt><dd class="mt-1 font-mono">{{ publishReceipt.id }}</dd></div>
<div><dt class="text-xs text-muted-fg">Checksum</dt><dd class="mt-1 font-mono">{{ publishReceipt.checksum }}</dd></div>
<div><dt class="text-xs text-muted-fg">Published</dt><dd class="mt-1">{{ formatTimestamp(publishReceipt.publishedAt) }}</dd></div>
<div><dt class="text-xs text-muted-fg">Editor</dt><dd class="mt-1">{{ publishReceipt.publishedBy }}</dd></div>
</dl>
</div>
<div v-else class="space-y-4">
<DomAlert
:tone="validation.blockerCount ? 'danger' : 'success'"
:title="validation.blockerCount ? `${validation.blockerCount} blocker${validation.blockerCount === 1 ? '' : 's'} must be resolved` : 'All required checks passed'"
:description="validation.blockerCount ? 'Select a marked item in the hierarchy, fix its destination, and run checks again.' : `Version ${draft?.publishedVersion + 1} can be published to ${bootstrap?.workspace.environment}.`"
/>
<div class="grid gap-2">
<div v-for="check in validation.checks" :key="check.key" class="flex items-center justify-between border-b border-border py-2 text-sm">
<span>{{ check.label }}</span>
<DomStatusPill :tone="check.healthy ? 'success' : 'danger'" :label="check.healthy ? 'Passed' : 'Blocked'" size="sm" />
</div>
</div>
<DomTextareaInput v-model="releaseNote" label="Release note" :rows="3" />
</div>
<template #footer>
<DomButton variant="secondary" size="sm" @click="closePublishDialog">{{ publishReceipt ? 'Done' : 'Keep editing' }}</DomButton>
<DomButton
v-if="!publishReceipt"
size="sm"
:loading="publishing"
:disabled="validation.blockerCount > 0"
@click="publishDraft"
>
Publish version {{ draft?.publishedVersion + 1 }}
</DomButton>
</template>
</DomDialog>
</div>
</template>
Local components
Copy the editor helpers
<script setup>
import { computed } from 'vue';
import { DomIconButton, DomStatusPill, DomTreeView } from '@getdom/studio/vue';
const props = defineProps({
items: {
type: Array,
default: () => [],
},
selectedId: {
type: String,
default: '',
},
validation: {
type: Object,
default: () => ({ issues: [] }),
},
reordering: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['add', 'select', 'reorder']);
const blockerIds = computed(getBlockerIds);
/**
* Return the navigation item identifiers with blocking validation issues.
*
* @returns {Set<string>} Item identifiers with blockers.
*/
function getBlockerIds() {
return new Set((props.validation?.issues || [])
.filter((issue) => issue.severity === 'blocker')
.map((issue) => issue.itemId));
}
/**
* Map a navigation visibility rule to a compact human-readable label.
*
* @param {string} visibility Visibility rule value.
* @returns {string} Compact visibility label.
*/
function visibilityLabel(visibility) {
if (visibility === 'roles') return 'Gated';
if (visibility === 'admins') return 'Admin';
if (visibility === 'hidden') return 'Hidden';
return '';
}
/**
* Map a navigation visibility rule to a DOM Studio status tone.
*
* @param {string} visibility Visibility rule value.
* @returns {string} Status pill tone.
*/
function visibilityTone(visibility) {
if (visibility === 'admins') return 'danger';
if (visibility === 'hidden') return 'warning';
return 'primary';
}
/**
* Forward tree selection using only the stable item identifier.
*
* @param {Record<string, unknown>} payload DomTreeView selection payload.
* @returns {void}
*/
function selectTreeItem(payload) {
emit('select', String(payload.value));
}
</script>
<template>
<div class="flex h-full min-h-0 flex-col bg-canvas">
<div class="shrink-0 border-b border-border px-3 py-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="font-semibold tracking-tight">Primary navigation</p>
<p class="mt-0.5 text-xs text-muted-fg">Drag or use Alt + arrow keys to reorder</p>
</div>
<DomIconButton
label="Add navigation item"
size="sm"
variant="secondary"
icon="M12 5v14M5 12h14"
@click="emit('add')"
/>
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-2">
<DomTreeView
:model-value="props.selectedId"
:items="props.items"
label="Application navigation hierarchy"
:open-values="['projects']"
variant="finder"
density="compact"
:draggable="!props.reordering"
@select="selectTreeItem"
@update:items="emit('reorder', $event)"
>
<template #item="{ item, selected }">
<span class="flex min-w-0 flex-1 items-center gap-2">
<span
v-if="blockerIds.has(item.id)"
class="size-2 shrink-0 rounded-full bg-destructive"
aria-label="Publishing blocker"
></span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium" :class="selected ? 'text-canvas-fg' : 'text-muted-fg'">{{ item.label }}</span>
<span class="block truncate text-[11px] text-muted-fg/75">{{ item.url }}</span>
</span>
<DomStatusPill
v-if="visibilityLabel(item.visibility)"
:tone="visibilityTone(item.visibility)"
:label="visibilityLabel(item.visibility)"
size="sm"
:dot="false"
/>
</span>
</template>
</DomTreeView>
</div>
<div class="shrink-0 border-t border-border px-3 py-3 text-xs text-muted-fg">
<div class="flex items-center justify-between gap-3">
<span>{{ props.validation?.blockerCount || 0 }} blocker{{ props.validation?.blockerCount === 1 ? '' : 's' }}</span>
<span v-if="props.reordering">Saving order…</span>
<span v-else>{{ props.items.length }} top-level items</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import {
DomAlert,
DomButton,
DomIconSelector,
DomSelect,
DomStatusPill,
DomTagCombobox,
DomTextInput,
DomTextareaInput,
DomToggle,
} from '@getdom/studio/vue';
const model = defineModel({
type: Object,
required: true,
});
const props = defineProps({
visibilityOptions: {
type: Array,
default: () => [],
},
audienceOptions: {
type: Array,
default: () => [],
},
iconOptions: {
type: Array,
default: () => [],
},
issues: {
type: Array,
default: () => [],
},
path: {
type: String,
default: '',
},
saving: {
type: Boolean,
default: false,
},
dirty: {
type: Boolean,
default: false,
},
hasChildren: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['save', 'archive', 'floating-interaction']);
const primaryIssue = computed(getPrimaryIssue);
const issueTone = computed(getIssueTone);
/**
* Return the first server-owned issue associated with the selected item.
*
* @returns {Record<string, unknown>|null} Highest-priority validation issue.
*/
function getPrimaryIssue() {
return props.issues.find((issue) => issue.severity === 'blocker') || props.issues[0] || null;
}
/**
* Map the selected issue severity to a DOM Studio alert tone.
*
* @returns {string} Alert tone.
*/
function getIssueTone() {
return primaryIssue.value?.severity === 'blocker' ? 'danger' : 'warning';
}
/**
* Keep a containing drawer open after a teleported picker commits a value.
*
* @returns {void}
*/
function notifyFloatingInteraction() {
setTimeout(() => emit('floating-interaction'), 0);
}
</script>
<template>
<div class="flex h-full min-h-0 flex-col bg-canvas">
<div class="shrink-0 border-b border-border px-4 py-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-fg">Selected destination</p>
<h2 class="mt-1 truncate text-lg font-semibold tracking-tight">{{ model.label }}</h2>
<p class="mt-1 truncate text-xs text-muted-fg">{{ props.path }}</p>
</div>
<DomStatusPill
:tone="primaryIssue ? issueTone : 'success'"
:label="primaryIssue ? (primaryIssue.severity === 'blocker' ? 'Needs attention' : 'Review') : 'Ready'"
size="sm"
/>
</div>
</div>
<div class="min-h-0 flex-1 space-y-5 overflow-y-auto px-4 py-5">
<DomAlert
v-if="primaryIssue"
:tone="issueTone"
variant="soft"
:title="primaryIssue.severity === 'blocker' ? 'Publishing blocker' : 'Check recommended'"
:description="primaryIssue.message"
/>
<section class="space-y-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-muted-fg">Content</p>
<p class="mt-1 text-xs leading-5 text-muted-fg">The label and destination people see in the app shell.</p>
</div>
<DomTextInput v-model="model.label" label="Menu label" placeholder="Operations" />
<DomTextInput
v-model="model.url"
label="Destination"
placeholder="/app/ops"
:invalid="primaryIssue?.field === 'url'"
:error="primaryIssue?.field === 'url' ? primaryIssue.message : ''"
/>
<DomTextareaInput
v-model="model.description"
label="Internal description"
placeholder="Explain where this item sends people"
:rows="3"
/>
<DomIconSelector
v-model="model.icon"
label="Navigation icon"
kind="custom"
:items="props.iconOptions"
:include-built-ins="false"
:clearable="false"
placeholder="Search navigation icons"
@select="notifyFloatingInteraction"
/>
</section>
<section class="space-y-4 border-t border-border pt-5">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-muted-fg">Access</p>
<p class="mt-1 text-xs leading-5 text-muted-fg">Preview visibility here; route authorization remains server-owned.</p>
</div>
<DomSelect
v-model="model.visibility"
label="Visibility"
:options="props.visibilityOptions"
width="min-w-[18rem]"
@select="notifyFloatingInteraction"
>
<template #option="{ option }">
<span class="block">
<span class="block text-sm font-semibold">{{ option.label }}</span>
<span class="mt-0.5 block text-xs leading-5 opacity-75">{{ option.description }}</span>
</span>
</template>
</DomSelect>
<DomTagCombobox
v-if="model.visibility === 'roles' || model.visibility === 'admins'"
v-model="model.audience"
label="Allowed audiences"
:options="props.audienceOptions"
placeholder="Add roles or plan cohorts"
clearable
@select="notifyFloatingInteraction"
/>
<DomTextInput v-model="model.featureFlag" label="Feature flag" placeholder="Optional flag key" />
<div class="grid gap-3 rounded-lg border border-border bg-secondary/35 p-3">
<DomToggle v-model="model.requiresAuth" label="Requires sign in" />
<DomToggle v-model="model.openInNewTab" label="Open in a new tab" />
</div>
</section>
</div>
<div class="shrink-0 border-t border-border bg-canvas px-4 py-3">
<div class="flex items-center justify-between gap-3">
<DomButton
variant="ghost"
size="sm"
:disabled="props.hasChildren || props.saving"
@click="emit('archive')"
>
Archive
</DomButton>
<DomButton
size="sm"
:loading="props.saving"
:disabled="!props.dirty"
@click="emit('save')"
>
Save changes
</DomButton>
</div>
<p v-if="props.hasChildren" class="mt-2 text-xs leading-5 text-muted-fg">Move or archive child items before archiving this parent.</p>
</div>
</div>
</template>
<script setup>
import { DomBadge, DomIconButton } from '@getdom/studio/vue';
defineOptions({
name: 'PreviewMenuItem',
});
const props = defineProps({
item: {
type: Object,
required: true,
},
selectedId: {
type: String,
default: '',
},
depth: {
type: Number,
default: 0,
},
compact: {
type: Boolean,
default: false,
},
});
defineEmits(['select']);
/**
* Return the short audience marker shown beside gated preview rows.
*
* @param {Record<string, unknown>} item Navigation preview item.
* @returns {string} Short policy label.
*/
function audienceLabel(item) {
if (item.visibility === 'admins') return 'Admin';
if (item.visibility === 'roles') return 'Gated';
return '';
}
</script>
<template>
<div class="grid gap-0.5">
<button
type="button"
class="group flex min-h-10 w-full items-center gap-2 rounded-md pr-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-canvas-fg' : 'text-muted-fg'"
:style="{ paddingLeft: `${0.5 + depth * 0.75}rem` }"
@click="$emit('select', item.id)"
>
<DomIconButton
as="span"
class="pointer-events-none"
size="xs"
:active="selectedId === item.id"
:icon="item.icon"
:label="`${item.label} icon`"
/>
<span class="min-w-0 flex-1 truncate font-medium text-canvas-fg">{{ item.label }}</span>
<DomBadge
v-if="audienceLabel(item) && !compact"
tone="neutral"
variant="outline"
size="sm"
>
{{ audienceLabel(item) }}
</DomBadge>
</button>
<div v-if="item.children?.length" class="grid gap-0.5">
<PreviewMenuItem
v-for="child in item.children"
:key="child.id"
:item="child"
:selected-id="props.selectedId"
:depth="props.depth + 1"
:compact="props.compact"
@select="$emit('select', $event)"
/>
</div>
</div>
</template>
Integration
How the working section fits together
Use this block when builders need to manage application navigation as versioned product data instead of a hard-coded route file. It combines a draggable hierarchy, focused metadata editing, server-filtered audience previews, route health checks, and deployment proof.
GET /api/block-demos/app-navigation-builder/bootstrapreturns the draft, hierarchy, editor options, and server validation report.- Item create, patch, archive, and reorder routes require the current draft revision and return
409for stale writes. - The preview route resolves representative audience entitlements on the server before returning visible menu items.
- The validation route checks registered routes, feature flags, audience rules, and hierarchy depth before publishing.
- Publishing creates a new immutable version with a timestamped checksum receipt suitable for audit history.
Data
API-backed publish receipt
{
id: 'nav-publish-801',
version: 19,
releaseNote: 'Fix operations destination and refresh audience navigation.',
publishedBy: 'Maya Chen',
itemCount: 9,
checksum: '3d9002616b46f1db'
}Customization
Implementation notes
Tree persistence
The demo persists the complete nested identifier tree, validates uniqueness and depth, then recomputes parent IDs and sibling sort orders server-side.
Audience rules
Audience previews make visibility understandable, while the example keeps route authorization explicitly separate from hiding navigation links.
Production boundary
The included API is process-local. Production should add authentication, authorization, durable versions, route discovery, deploy orchestration, and immutable audit storage.