From d90d6623625361395eb0624fcfd0298e65b9bcfc Mon Sep 17 00:00:00 2001 From: Royce Williams Date: Thu, 30 Jul 2026 14:20:15 -0700 Subject: [PATCH 1/5] added interactive nesting component --- organize/navigation.mdx | 220 +------------------------------- snippets/navigation-builder.jsx | 147 +++++++++++++++++++++ 2 files changed, 151 insertions(+), 216 deletions(-) create mode 100644 snippets/navigation-builder.jsx diff --git a/organize/navigation.mdx b/organize/navigation.mdx index 71ff82ecd..278ce3f6e 100644 --- a/organize/navigation.mdx +++ b/organize/navigation.mdx @@ -5,6 +5,8 @@ keywords: ["navigation structure", "sidebar configuration", "page organization", boost: 3 --- +import { NavigationBuilder } from "/snippets/navigation-builder.jsx"; + The [navigation](/organize/settings-structure#navigation) property in `docs.json` controls the structure and information hierarchy of your documentation. With proper navigation configuration, you can organize your content so that users can find exactly what they're looking for. @@ -870,223 +872,9 @@ You can nest navigation elements within each other to create complex hierarchies Each navigation element can contain one type of child element at each level of your navigation hierarchy. For example, a tab can contain anchors that contain groups, but a tab cannot contain both anchors and groups at the same level. - - -```json Tabs containing anchors -{ - "navigation": { - "tabs": [ - { - "tab": "Documentation", - "anchors": [ - { - "anchor": "Guides", - "icon": "book-open", - "pages": ["quickstart", "tutorial"] - }, - { - "anchor": "API Reference", - "icon": "code", - "pages": ["api/overview", "api/endpoints"] - } - ] - }, - { - "tab": "Resources", - "groups": [ - { - "group": "Help", - "pages": ["support", "faq"] - } - ] - } - ] - } -} -``` - -```json Anchors containing tabs -{ - "navigation": { - "anchors": [ - { - "anchor": "Documentation", - "icon": "book-open", - "tabs": [ - { - "tab": "Guides", - "pages": ["quickstart", "tutorial"] - }, - { - "tab": "API", - "pages": ["api/overview", "api/endpoints"] - } - ] - }, - { - "anchor": "Community", - "icon": "users", - "href": "https://community.example.com" - } - ] - } -} -``` - -```json Products containing tabs -{ - "navigation": { - "products": [ - { - "product": "Platform", - "icon": "server", - "tabs": [ - { - "tab": "Documentation", - "groups": [ - { - "group": "Getting started", - "pages": ["platform/quickstart"] - } - ] - }, - { - "tab": "API Reference", - "pages": ["platform/api"] - } - ] - }, - { - "product": "Mobile SDK", - "icon": "mobile", - "pages": ["mobile/overview"] - } - ] - } -} -``` - -```json Multi-product SaaS with tabs and menu -{ - "navigation": { - "products": [ - { - "product": "Platform", - "icon": "cloud", - "tabs": [ - { - "tab": "Documentation", - "menu": [ - { - "item": "Getting Started", - "icon": "rocket", - "groups": [ - { - "group": "Setup", - "pages": ["platform/install", "platform/config"] - }, - { - "group": "Core Concepts", - "pages": ["platform/concepts/auth", "platform/concepts/data"] - } - ] - }, - { - "item": "Guides", - "icon": "book", - "pages": ["platform/guides/deployment", "platform/guides/scaling"] - } - ] - }, - { - "tab": "API Reference", - "groups": [ - { - "group": "REST API", - "pages": ["platform/api/users", "platform/api/projects"] - }, - { - "group": "GraphQL", - "pages": ["platform/api/graphql/queries", "platform/api/graphql/mutations"] - } - ] - } - ] - }, - { - "product": "Analytics", - "icon": "chart-bar", - "tabs": [ - { - "tab": "Documentation", - "groups": [ - { - "group": "Getting Started", - "pages": ["analytics/quickstart", "analytics/setup"] - } - ] - }, - { - "tab": "API", - "pages": ["analytics/api/events", "analytics/api/reports"] - } - ] - } - ] - } -} -``` - -```json Versioned docs with tabs -{ - "navigation": { - "versions": [ - { - "version": "v2.0", - "tabs": [ - { - "tab": "Documentation", - "groups": [ - { - "group": "Getting Started", - "pages": ["v2/quickstart", "v2/migration-from-v1"] - }, - { - "group": "Features", - "pages": ["v2/features/auth", "v2/features/api"] - } - ] - }, - { - "tab": "API Reference", - "pages": ["v2/api/overview", "v2/api/endpoints"] - } - ] - }, - { - "version": "v1.0", - "tabs": [ - { - "tab": "Documentation", - "groups": [ - { - "group": "Getting Started", - "pages": ["v1/quickstart"] - } - ] - }, - { - "tab": "API Reference", - "pages": ["v1/api/overview"] - } - ] - } - ] - } -} -``` +Use this builder to assemble a hierarchy and generate the matching `docs.json` structure. Select elements from the outside in, then copy the result. - + ## Breadcrumbs diff --git a/snippets/navigation-builder.jsx b/snippets/navigation-builder.jsx new file mode 100644 index 000000000..df1376bbb --- /dev/null +++ b/snippets/navigation-builder.jsx @@ -0,0 +1,147 @@ +export const NavigationBuilder = () => { + const DIVISIONS = { + languages: { label: "Languages", key: "language", sample: "en" }, + versions: { label: "Versions", key: "version", sample: "v2" }, + products: { label: "Products", key: "product", sample: "Platform" }, + dropdowns: { label: "Dropdowns", key: "dropdown", sample: "Documentation" }, + tabs: { label: "Tabs", key: "tab", sample: "Guides" }, + anchors: { label: "Anchors", key: "anchor", sample: "API reference" }, + menu: { label: "Menu", key: "item", sample: "Getting started" }, + groups: { label: "Groups", key: "group", sample: "Essentials" }, + pages: { label: "Pages" }, + } + + const CHILDREN = { + navigation: ["languages", "versions", "products", "dropdowns", "tabs", "anchors", "groups", "pages"], + languages: ["versions", "products", "dropdowns", "tabs", "anchors", "groups", "pages"], + versions: ["languages", "products", "dropdowns", "tabs", "anchors", "groups", "pages"], + products: ["languages", "versions", "dropdowns", "tabs", "anchors", "menu", "groups", "pages"], + dropdowns: ["languages", "versions", "products", "tabs", "anchors", "groups", "pages"], + tabs: ["languages", "versions", "products", "dropdowns", "anchors", "menu", "groups", "pages"], + anchors: ["languages", "versions", "products", "dropdowns", "tabs", "groups", "pages"], + menu: ["languages", "versions", "products", "dropdowns", "tabs", "anchors", "groups", "pages"], + groups: ["pages"], + pages: [], + } + + const [chain, setChain] = useState([]) + const [copied, setCopied] = useState(false) + + const parent = chain.length ? chain[chain.length - 1] : "navigation" + const options = CHILDREN[parent].filter((division) => !chain.includes(division)) + + const buildNode = (index) => { + const division = chain[index] + if (!division || division === "pages") { + return { pages: ["index", "quickstart", "settings"] } + } + const { key, sample } = DIVISIONS[division] + return { [division]: [{ [key]: sample, ...buildNode(index + 1) }] } + } + + const output = JSON.stringify({ navigation: buildNode(0) }, null, 2) + + const copyToClipboard = () => { + navigator.clipboard + .writeText(output) + .then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + .catch((err) => { + console.error("Failed to copy: ", err) + }) + } + + return ( +
+
+
+

+ Select navigation elements from the outside in. +

+ {chain.length > 0 && ( + + )} +
+ +
+ + navigation + + {chain.map((division, index) => ( + + + + + ))} +
+
+ + {options.length > 0 ? ( +
+

+ {chain.length === 0 + ? "Choose a root element:" + : `Nest inside ${DIVISIONS[parent].label.toLowerCase()}:`} +

+
+ {options.map((division) => ( + + ))} +
+
+ ) : ( +

+ Pages are the deepest level of a navigation hierarchy. +

+ )} + +
+ +
+          {output}
+        
+
+ +

+ Each array contains one example entry. Add as many entries as you need at any level. +

+
+ ) +} From 8953cc759c5190bbacf68eb1bb3835112c91bf82 Mon Sep 17 00:00:00 2001 From: Ethan Palm <56270045+ethanpalm@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:49:14 -0700 Subject: [PATCH 2/5] add nav builder --- organize/navigation.mdx | 2 +- snippets/navigation-builder.jsx | 373 +++++++++++++++++++++++++------- 2 files changed, 291 insertions(+), 84 deletions(-) diff --git a/organize/navigation.mdx b/organize/navigation.mdx index 278ce3f6e..8e0ec948b 100644 --- a/organize/navigation.mdx +++ b/organize/navigation.mdx @@ -872,7 +872,7 @@ You can nest navigation elements within each other to create complex hierarchies Each navigation element can contain one type of child element at each level of your navigation hierarchy. For example, a tab can contain anchors that contain groups, but a tab cannot contain both anchors and groups at the same level. -Use this builder to assemble a hierarchy and generate the matching `docs.json` structure. Select elements from the outside in, then copy the result. +Use this builder to assemble a hierarchy and generate the matching `docs.json` structure. Choose a root pattern, add and nest as many elements as you need at each level, rename them, then copy the result. diff --git a/snippets/navigation-builder.jsx b/snippets/navigation-builder.jsx index df1376bbb..50675f69a 100644 --- a/snippets/navigation-builder.jsx +++ b/snippets/navigation-builder.jsx @@ -1,18 +1,21 @@ export const NavigationBuilder = () => { + // Mintlify evaluates snippet exports independently, so shared values must stay in this scope. const DIVISIONS = { - languages: { label: "Languages", key: "language", sample: "en" }, - versions: { label: "Versions", key: "version", sample: "v2" }, - products: { label: "Products", key: "product", sample: "Platform" }, - dropdowns: { label: "Dropdowns", key: "dropdown", sample: "Documentation" }, - tabs: { label: "Tabs", key: "tab", sample: "Guides" }, - anchors: { label: "Anchors", key: "anchor", sample: "API reference" }, - menu: { label: "Menu", key: "item", sample: "Getting started" }, - groups: { label: "Groups", key: "group", sample: "Essentials" }, - pages: { label: "Pages" }, + languages: { singular: "language", pluralLabel: "Languages", placeholder: "en" }, + versions: { singular: "version", pluralLabel: "Versions", placeholder: "v1" }, + products: { singular: "product", pluralLabel: "Products", placeholder: "Platform" }, + dropdowns: { singular: "dropdown", pluralLabel: "Dropdowns", placeholder: "Documentation" }, + tabs: { singular: "tab", pluralLabel: "Tabs", placeholder: "Guides" }, + anchors: { singular: "anchor", pluralLabel: "Anchors", placeholder: "API reference" }, + menu: { singular: "item", pluralLabel: "Menu items", placeholder: "Getting started" }, + groups: { singular: "group", pluralLabel: "Groups", placeholder: "Getting started" }, } - const CHILDREN = { - navigation: ["languages", "versions", "products", "dropdowns", "tabs", "anchors", "groups", "pages"], + // Which child divisions are valid inside a given division, in the order buttons are shown. + // Mirrors the nesting rules in /organize/navigation: each level holds only one child type, + // and a group's own children always live in a "pages" array (which can itself nest groups). + const CHILD_OPTIONS = { + root: ["languages", "versions", "products", "dropdowns", "tabs", "anchors", "groups", "pages"], languages: ["versions", "products", "dropdowns", "tabs", "anchors", "groups", "pages"], versions: ["languages", "products", "dropdowns", "tabs", "anchors", "groups", "pages"], products: ["languages", "versions", "dropdowns", "tabs", "anchors", "menu", "groups", "pages"], @@ -21,25 +24,127 @@ export const NavigationBuilder = () => { anchors: ["languages", "versions", "products", "dropdowns", "tabs", "groups", "pages"], menu: ["languages", "versions", "products", "dropdowns", "tabs", "anchors", "groups", "pages"], groups: ["pages"], - pages: [], } - const [chain, setChain] = useState([]) + const idRef = useRef(0) + const nextId = (prefix) => `${prefix}-${++idRef.current}` + + const createContainer = (division) => ({ + id: nextId(division), + type: "container", + division, + label: "", + childDivision: null, + children: [], + pages: [], + }) + const createLeaf = () => ({ id: nextId("page"), type: "leaf", value: "" }) + + const [rootDivision, setRootDivision] = useState("groups") + const [entries, setEntries] = useState([ + { + id: "seed-group", + type: "container", + division: "groups", + label: "Getting started", + childDivision: "pages", + children: [], + pages: [ + { id: "seed-page-1", type: "leaf", value: "index" }, + { id: "seed-page-2", type: "leaf", value: "quickstart" }, + { id: "seed-page-3", type: "leaf", value: "development" }, + ], + }, + ]) const [copied, setCopied] = useState(false) - const parent = chain.length ? chain[chain.length - 1] : "navigation" - const options = CHILDREN[parent].filter((division) => !chain.includes(division)) + const mapTree = (items, id, fn) => + items.map((item) => { + if (item.id === id) return fn(item) + if (item.type !== "container") return item + return { + ...item, + children: item.children.length ? mapTree(item.children, id, fn) : item.children, + pages: item.pages.length ? mapTree(item.pages, id, fn) : item.pages, + } + }) + + const removeTree = (items, id) => + items + .filter((item) => item.id !== id) + .map((item) => + item.type === "container" + ? { ...item, children: removeTree(item.children, id), pages: removeTree(item.pages, id) } + : item, + ) - const buildNode = (index) => { - const division = chain[index] - if (!division || division === "pages") { - return { pages: ["index", "quickstart", "settings"] } + const updateLabel = (id, value) => + setEntries((items) => mapTree(items, id, (item) => ({ ...item, label: value }))) + const updatePageValue = (id, value) => + setEntries((items) => mapTree(items, id, (item) => ({ ...item, value }))) + const removeNode = (id) => setEntries((items) => removeTree(items, id)) + const setChildDivision = (id, division) => + setEntries((items) => + mapTree(items, id, (item) => ({ + ...item, + childDivision: division, + children: division !== "pages" ? [createContainer(division)] : [], + pages: division === "pages" ? [createLeaf()] : [], + })), + ) + const clearChildDivision = (id) => + setEntries((items) => + mapTree(items, id, (item) => ({ ...item, childDivision: null, children: [], pages: [] })), + ) + const addChildContainer = (id) => + setEntries((items) => + mapTree(items, id, (item) => ({ + ...item, + children: [...item.children, createContainer(item.childDivision)], + })), + ) + const addPageLeaf = (id) => + setEntries((items) => mapTree(items, id, (item) => ({ ...item, pages: [...item.pages, createLeaf()] }))) + const addNestedGroup = (id) => + setEntries((items) => + mapTree(items, id, (item) => ({ ...item, pages: [...item.pages, createContainer("groups")] })), + ) + + const chooseRootDivision = (division) => { + setRootDivision(division) + setEntries([division === "pages" ? createLeaf() : createContainer(division)]) + } + const resetRoot = () => { + setRootDivision(null) + setEntries([]) + } + const addRootEntry = () => + setEntries((items) => [...items, rootDivision === "pages" ? createLeaf() : createContainer(rootDivision)]) + + const containerToJSON = (node) => { + const meta = DIVISIONS[node.division] + const json = { [meta.singular]: node.label.trim() || meta.placeholder } + if (node.childDivision === "pages") { + json.pages = node.pages.map(pageEntryToJSON) + } else if (node.childDivision) { + json[node.childDivision] = node.children.map(containerToJSON) } - const { key, sample } = DIVISIONS[division] - return { [division]: [{ [key]: sample, ...buildNode(index + 1) }] } + return json } + const pageEntryToJSON = (entry) => + entry.type === "leaf" ? entry.value.trim() || "page-path" : containerToJSON(entry) - const output = JSON.stringify({ navigation: buildNode(0) }, null, 2) + const output = JSON.stringify( + { + navigation: !rootDivision + ? {} + : rootDivision === "pages" + ? { pages: entries.map(pageEntryToJSON) } + : { [rootDivision]: entries.map(containerToJSON) }, + }, + null, + 2, + ) const copyToClipboard = () => { navigator.clipboard @@ -53,74 +158,180 @@ export const NavigationBuilder = () => { }) } + const optionButtonClasses = + "px-2.5 py-1 text-sm rounded-lg border border-zinc-950/10 dark:border-white/10 text-zinc-950/70 dark:text-white/70 hover:bg-zinc-950/5 dark:hover:bg-white/10 transition-colors" + const addButtonClasses = + "px-2.5 py-1 text-sm rounded-lg border border-dashed border-zinc-950/20 dark:border-white/20 text-zinc-950/70 dark:text-white/70 hover:bg-zinc-950/5 dark:hover:bg-white/10 transition-colors" + const tagClasses = + "shrink-0 px-2 py-1 rounded-md font-mono text-xs bg-zinc-950/5 dark:bg-white/10 text-zinc-950/50 dark:text-white/50" + const inputClasses = + "flex-1 min-w-0 px-2 py-1 text-sm rounded-md border border-zinc-950/10 dark:border-white/10 bg-transparent" + + const renderRemoveButton = (id, label) => ( + + ) + + const renderPageLeaf = (entry) => ( +
+ page + updatePageValue(entry.id, event.target.value)} + placeholder="quickstart" + aria-label="Page path" + className={`${inputClasses} font-mono`} + /> + {renderRemoveButton(entry.id, "page")} +
+ ) + + const renderContainer = (node) => { + const meta = DIVISIONS[node.division] + const childOptions = CHILD_OPTIONS[node.division] || [] + + return ( +
+
+ {meta.singular} + updateLabel(node.id, event.target.value)} + placeholder={meta.placeholder} + aria-label={`${meta.pluralLabel} label`} + className={inputClasses} + /> + {renderRemoveButton(node.id, meta.singular)} +
+ + {node.childDivision ? ( +
+ {node.childDivision === "pages" ? ( + <> +
+ {node.pages.map((entry) => + entry.type === "leaf" ? renderPageLeaf(entry) : renderContainer(entry), + )} +
+
+ + +
+ + ) : ( + <> +
{node.children.map((child) => renderContainer(child))}
+ + + )} + +
+ ) : ( +
+
Nest inside {meta.singular}:
+
+ {childOptions.map((option) => ( + + ))} +
+
+ )} +
+ ) + } + return ( -
-
-
-

- Select navigation elements from the outside in. -

- {chain.length > 0 && ( +
+
+
+
+
Navigation builder
+
+ Add and nest as many entries as you need, then copy the generated docs.json. +
+
+ {rootDivision && ( )}
-
- - navigation - - {chain.map((division, index) => ( - -
+ +
+ {entries.map((entry) => (entry.type === "leaf" ? renderPageLeaf(entry) : renderContainer(entry)))} +
- {options.length > 0 ? ( -
-

- {chain.length === 0 - ? "Choose a root element:" - : `Nest inside ${DIVISIONS[parent].label.toLowerCase()}:`} -

-
- {options.map((division) => ( - - ))} +
-
- ) : ( -

- Pages are the deepest level of a navigation hierarchy. -

- )} + )} +
-
+
-
+        
           {output}
         
- -

- Each array contains one example entry. Add as many entries as you need at any level. -

) } From c8121c7c2e11be78d77170399e24ae2cea689cff Mon Sep 17 00:00:00 2001 From: Ethan Palm <56270045+ethanpalm@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:00:54 -0700 Subject: [PATCH 3/5] combine preview and JSON copy block --- organize/navigation.mdx | 2 +- snippets/navigation-builder.jsx | 219 +++++++++++++++++++++++++++++--- 2 files changed, 203 insertions(+), 18 deletions(-) diff --git a/organize/navigation.mdx b/organize/navigation.mdx index 8e0ec948b..f7ef48cd6 100644 --- a/organize/navigation.mdx +++ b/organize/navigation.mdx @@ -872,7 +872,7 @@ You can nest navigation elements within each other to create complex hierarchies Each navigation element can contain one type of child element at each level of your navigation hierarchy. For example, a tab can contain anchors that contain groups, but a tab cannot contain both anchors and groups at the same level. -Use this builder to assemble a hierarchy and generate the matching `docs.json` structure. Choose a root pattern, add and nest as many elements as you need at each level, rename them, then copy the result. +Use this builder to assemble a hierarchy and generate the matching `docs.json` structure. Choose a root pattern, add and nest as many elements as you need at each level, rename them, then copy the result. The preview shows how each element renders and lets you click between tabs, anchors, dropdowns, and other switchers to see how visitors move through them. diff --git a/snippets/navigation-builder.jsx b/snippets/navigation-builder.jsx index 50675f69a..35a72f818 100644 --- a/snippets/navigation-builder.jsx +++ b/snippets/navigation-builder.jsx @@ -57,6 +57,70 @@ export const NavigationBuilder = () => { }, ]) const [copied, setCopied] = useState(false) + const [previewActive, setPreviewActive] = useState({}) + const [rightPanelTab, setRightPanelTab] = useState("preview") + + // Divisions where only one sibling is visible at a time (a switcher the visitor + // clicks between), as opposed to groups/pages, which all render together. + const SWITCHER_DIVISIONS = new Set([ + "languages", + "versions", + "products", + "dropdowns", + "tabs", + "anchors", + "menu", + ]) + // Visual treatment per switcher division, echoing how each renders in a live site: + // tabs underline like a real tab bar, anchors/dropdowns/products/menu read as buttons. + const SWITCHER_CHIP_CLASSES = { + tabs: (active) => + `px-1 pb-1 text-sm border-b-2 transition-colors ${ + active + ? "border-primary text-primary dark:text-primary-light font-medium" + : "border-transparent text-zinc-950/60 dark:text-white/60 hover:text-zinc-950/90 dark:hover:text-white/90" + }`, + dropdowns: (active) => + `px-2.5 py-1 text-sm rounded-full transition-colors ${ + active + ? "bg-primary text-white" + : "bg-zinc-950/5 dark:bg-white/10 text-zinc-950/70 dark:text-white/70" + }`, + products: (active) => + `px-2.5 py-1 text-sm rounded-md border transition-colors ${ + active + ? "border-primary text-primary dark:text-primary-light" + : "border-zinc-950/10 dark:border-white/10 text-zinc-950/70 dark:text-white/70" + }`, + versions: (active) => + `px-2 py-0.5 text-xs font-mono rounded-md transition-colors ${ + active + ? "bg-primary/10 text-primary dark:text-primary-light" + : "bg-zinc-950/5 dark:bg-white/10 text-zinc-950/60 dark:text-white/60" + }`, + languages: (active) => + `px-2 py-0.5 text-xs uppercase tracking-wide rounded-md transition-colors ${ + active + ? "bg-primary/10 text-primary dark:text-primary-light" + : "bg-zinc-950/5 dark:bg-white/10 text-zinc-950/60 dark:text-white/60" + }`, + anchors: (active) => + `px-2.5 py-1 text-sm rounded-full border transition-colors ${ + active + ? "bg-primary/10 border-primary text-primary dark:text-primary-light" + : "border-zinc-950/10 dark:border-white/10 text-zinc-950/70 dark:text-white/70" + }`, + menu: (active) => + `px-2 py-1 text-xs rounded-md border border-dashed transition-colors ${ + active + ? "border-primary text-primary dark:text-primary-light" + : "border-zinc-950/20 dark:border-white/20 text-zinc-950/60 dark:text-white/60" + }`, + } + + const activeIndexFor = (key, length) => Math.min(previewActive[key] ?? 0, Math.max(length - 1, 0)) + const setPreviewActiveIndex = (key, index) => + setPreviewActive((prev) => ({ ...prev, [key]: index })) const mapTree = (items, id, fn) => items.map((item) => { @@ -274,6 +338,84 @@ export const NavigationBuilder = () => { ) } + const renderPreviewPage = (entry) => ( +
+
+ ) + + const renderPreviewGroup = (node) => ( +
+
+ {node.label.trim() || DIVISIONS.groups.placeholder} +
+ {node.childDivision === "pages" ? ( +
+ {node.pages.map((entry) => (entry.type === "leaf" ? renderPreviewPage(entry) : renderPreviewGroup(entry)))} +
+ ) : null} +
+ ) + + const renderSwitcherRow = (division, nodes, key, activeIdx) => { + const meta = DIVISIONS[division] + const chipClass = SWITCHER_CHIP_CLASSES[division] + const showChevron = division === "dropdowns" || division === "products" + return ( +
+
+ {meta.pluralLabel} +
+
+ {nodes.map((node, index) => ( + + ))} +
+
+ ) + } + + const renderPreviewLevel = (division, nodes, key) => { + if (!division || !nodes.length) { + return
Nothing nested here yet.
+ } + if (division === "pages") { + return
{nodes.map((entry) => renderPreviewPage(entry))}
+ } + if (division === "groups") { + return
{nodes.map((node) => renderPreviewGroup(node))}
+ } + + const activeIdx = activeIndexFor(key, nodes.length) + const activeNode = nodes[activeIdx] + return ( +
+ {renderSwitcherRow(division, nodes, key, activeIdx)} +
+ {activeNode + ? renderPreviewLevel( + activeNode.childDivision, + activeNode.childDivision === "pages" ? activeNode.pages : activeNode.children, + activeNode.id, + ) + : null} +
+
+ ) + } + return (
@@ -331,23 +473,66 @@ export const NavigationBuilder = () => { )}
-
- -
-          {output}
-        
+
+
+ + + +
+ + +
+ {rightPanelTab === "json" && ( + + )} +
+
+ {rightPanelTab === "preview" ? ( + rootDivision ? ( + renderPreviewLevel(rootDivision, entries, "root") + ) : ( +
+ Choose a root pattern to see a preview. +
+ ) + ) : ( +
+              {output}
+            
+ )} +
) From 47411500ec36f5ca220a7ec50b6561a42da15a27 Mon Sep 17 00:00:00 2001 From: Ethan Palm <56270045+ethanpalm@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:10:45 -0700 Subject: [PATCH 4/5] show accurate previews --- snippets/navigation-builder.jsx | 324 ++++++++++++++++++++++---------- 1 file changed, 223 insertions(+), 101 deletions(-) diff --git a/snippets/navigation-builder.jsx b/snippets/navigation-builder.jsx index 35a72f818..072783c12 100644 --- a/snippets/navigation-builder.jsx +++ b/snippets/navigation-builder.jsx @@ -58,69 +58,41 @@ export const NavigationBuilder = () => { ]) const [copied, setCopied] = useState(false) const [previewActive, setPreviewActive] = useState({}) + const [openDropdown, setOpenDropdown] = useState(null) const [rightPanelTab, setRightPanelTab] = useState("preview") - // Divisions where only one sibling is visible at a time (a switcher the visitor - // clicks between), as opposed to groups/pages, which all render together. - const SWITCHER_DIVISIONS = new Set([ - "languages", - "versions", - "products", - "dropdowns", - "tabs", - "anchors", - "menu", - ]) - // Visual treatment per switcher division, echoing how each renders in a live site: - // tabs underline like a real tab bar, anchors/dropdowns/products/menu read as buttons. - const SWITCHER_CHIP_CLASSES = { - tabs: (active) => - `px-1 pb-1 text-sm border-b-2 transition-colors ${ - active - ? "border-primary text-primary dark:text-primary-light font-medium" - : "border-transparent text-zinc-950/60 dark:text-white/60 hover:text-zinc-950/90 dark:hover:text-white/90" - }`, - dropdowns: (active) => - `px-2.5 py-1 text-sm rounded-full transition-colors ${ - active - ? "bg-primary text-white" - : "bg-zinc-950/5 dark:bg-white/10 text-zinc-950/70 dark:text-white/70" - }`, - products: (active) => - `px-2.5 py-1 text-sm rounded-md border transition-colors ${ - active - ? "border-primary text-primary dark:text-primary-light" - : "border-zinc-950/10 dark:border-white/10 text-zinc-950/70 dark:text-white/70" - }`, - versions: (active) => - `px-2 py-0.5 text-xs font-mono rounded-md transition-colors ${ - active - ? "bg-primary/10 text-primary dark:text-primary-light" - : "bg-zinc-950/5 dark:bg-white/10 text-zinc-950/60 dark:text-white/60" - }`, - languages: (active) => - `px-2 py-0.5 text-xs uppercase tracking-wide rounded-md transition-colors ${ - active - ? "bg-primary/10 text-primary dark:text-primary-light" - : "bg-zinc-950/5 dark:bg-white/10 text-zinc-950/60 dark:text-white/60" - }`, - anchors: (active) => - `px-2.5 py-1 text-sm rounded-full border transition-colors ${ - active - ? "bg-primary/10 border-primary text-primary dark:text-primary-light" - : "border-zinc-950/10 dark:border-white/10 text-zinc-950/70 dark:text-white/70" - }`, - menu: (active) => - `px-2 py-1 text-xs rounded-md border border-dashed transition-colors ${ - active - ? "border-primary text-primary dark:text-primary-light" - : "border-zinc-950/20 dark:border-white/20 text-zinc-950/60 dark:text-white/60" - }`, - } + // Divisions that render in the mock navbar, like a live site's top bar. + const NAVBAR_DIVISIONS = new Set(["tabs", "dropdowns", "products", "versions", "languages"]) + // Anything else (anchors, menu) pins above the sidebar tree instead: anchors stay always + // visible, a menu is the active tab/product's flyout. Groups/pages become the tree itself. const activeIndexFor = (key, length) => Math.min(previewActive[key] ?? 0, Math.max(length - 1, 0)) const setPreviewActiveIndex = (key, index) => setPreviewActive((prev) => ({ ...prev, [key]: index })) + const toggleDropdown = (key) => setOpenDropdown((prev) => (prev === key ? null : key)) + const closeDropdown = () => setOpenDropdown(null) + + useEffect(() => { + if (!openDropdown) return undefined + const closeOnPointerDown = (event) => { + if ( + event.target instanceof Element && + event.target.closest(`[data-preview-dropdown="${openDropdown}"]`) + ) { + return + } + setOpenDropdown(null) + } + const closeOnEscape = (event) => { + if (event.key === "Escape") setOpenDropdown(null) + } + document.addEventListener("pointerdown", closeOnPointerDown) + document.addEventListener("keydown", closeOnEscape) + return () => { + document.removeEventListener("pointerdown", closeOnPointerDown) + document.removeEventListener("keydown", closeOnEscape) + } + }, [openDropdown]) const mapTree = (items, id, fn) => items.map((item) => { @@ -361,25 +333,144 @@ export const NavigationBuilder = () => {
) - const renderSwitcherRow = (division, nodes, key, activeIdx) => { - const meta = DIVISIONS[division] - const chipClass = SWITCHER_CHIP_CLASSES[division] - const showChevron = division === "dropdowns" || division === "products" + // Chevron icon used on real dropdown/select-style controls. + const renderChevron = (isOpen) => ( + + ) + + // A real tab bar: plain text links, active tab underlined, like a live site's top navigation. + const renderTabsRow = (nodes, key, activeIdx, meta) => ( +
+ {nodes.map((node, index) => ( + + ))} +
+ ) + + // A real dropdown/select: a trigger button that opens a popover listing the other options. + const renderDropdownControl = (division, nodes, key, activeIdx, meta) => { + const activeNode = nodes[activeIdx] + const isOpen = openDropdown === key + const compact = division === "versions" || division === "languages" return ( -
-
- {meta.pluralLabel} +
+ + {isOpen ? ( +
+ {nodes.map((node, index) => ( + + ))} +
+ ) : null} +
+ ) + } + + const renderNavbarRow = (row) => { + const meta = DIVISIONS[row.division] + if (row.division === "tabs") return renderTabsRow(row.nodes, row.key, row.activeIdx, meta) + return renderDropdownControl(row.division, row.nodes, row.key, row.activeIdx, meta) + } + + // Anchors pin above the sidebar tree as always-visible buttons (no popover, since anchors + // are meant to stay in view). A menu is the tab/product flyout, shown as a strip of items. + const renderSidebarHeaderRow = (row) => { + const meta = DIVISIONS[row.division] + if (row.division === "anchors") { + return ( +
+ {row.nodes.map((node, index) => ( + + ))}
-
- {nodes.map((node, index) => ( + ) + } + return ( +
+
Menu
+
+ {row.nodes.map((node, index) => ( ))}
@@ -387,35 +478,39 @@ export const NavigationBuilder = () => { ) } - const renderPreviewLevel = (division, nodes, key) => { - if (!division || !nodes.length) { - return
Nothing nested here yet.
- } + // Walk down the active path, sorting each switcher level into the navbar or the sidebar + // header, until reaching the groups/pages tree that becomes the sidebar's main content. + const collectPreview = (division, nodes, key) => { + const empty = { navbarRows: [], sidebarHeaderRows: [], sidebarContent: null } + if (!division || !nodes.length) return empty if (division === "pages") { - return
{nodes.map((entry) => renderPreviewPage(entry))}
+ return { ...empty, sidebarContent:
{nodes.map((entry) => renderPreviewPage(entry))}
} } if (division === "groups") { - return
{nodes.map((node) => renderPreviewGroup(node))}
+ return { ...empty, sidebarContent:
{nodes.map((node) => renderPreviewGroup(node))}
} } const activeIdx = activeIndexFor(key, nodes.length) const activeNode = nodes[activeIdx] - return ( -
- {renderSwitcherRow(division, nodes, key, activeIdx)} -
- {activeNode - ? renderPreviewLevel( - activeNode.childDivision, - activeNode.childDivision === "pages" ? activeNode.pages : activeNode.children, - activeNode.id, - ) - : null} -
-
- ) + const row = { division, nodes, key, activeIdx } + const rest = activeNode + ? collectPreview( + activeNode.childDivision, + activeNode.childDivision === "pages" ? activeNode.pages : activeNode.children, + activeNode.id, + ) + : empty + + if (NAVBAR_DIVISIONS.has(division)) { + return { ...rest, navbarRows: [row, ...rest.navbarRows] } + } + return { ...rest, sidebarHeaderRows: [row, ...rest.sidebarHeaderRows] } } + const preview = rootDivision + ? collectPreview(rootDivision, entries, "root") + : { navbarRows: [], sidebarHeaderRows: [], sidebarContent: null } + return (
@@ -473,8 +568,9 @@ export const NavigationBuilder = () => { )}
-
-
+
+ {/* Rounded directly (not via a clipping parent) so the dropdown popovers below aren't cut off. */} +
@@ -518,21 +614,47 @@ export const NavigationBuilder = () => { )}
-
- {rightPanelTab === "preview" ? ( - rootDivision ? ( - renderPreviewLevel(rootDivision, entries, "root") - ) : ( -
- Choose a root pattern to see a preview. + {rightPanelTab === "preview" ? ( + rootDivision ? ( +
+
+
+
+ {preview.navbarRows.length ? ( +
+ {preview.navbarRows.map((row) => renderNavbarRow(row))} +
+ ) : null}
- ) +
+
+ {preview.sidebarHeaderRows.map((row) => renderSidebarHeaderRow(row))} + {preview.sidebarContent || ( +
Nothing nested here yet.
+ )} +
+
) From 0631ebe0d0ee7a75a70a092c26b15ff04163bea8 Mon Sep 17 00:00:00 2001 From: Ethan Palm <56270045+ethanpalm@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:15:13 -0700 Subject: [PATCH 5/5] remove default groups --- snippets/navigation-builder.jsx | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/snippets/navigation-builder.jsx b/snippets/navigation-builder.jsx index 072783c12..54cf630b6 100644 --- a/snippets/navigation-builder.jsx +++ b/snippets/navigation-builder.jsx @@ -40,22 +40,8 @@ export const NavigationBuilder = () => { }) const createLeaf = () => ({ id: nextId("page"), type: "leaf", value: "" }) - const [rootDivision, setRootDivision] = useState("groups") - const [entries, setEntries] = useState([ - { - id: "seed-group", - type: "container", - division: "groups", - label: "Getting started", - childDivision: "pages", - children: [], - pages: [ - { id: "seed-page-1", type: "leaf", value: "index" }, - { id: "seed-page-2", type: "leaf", value: "quickstart" }, - { id: "seed-page-3", type: "leaf", value: "development" }, - ], - }, - ]) + const [rootDivision, setRootDivision] = useState(null) + const [entries, setEntries] = useState([]) const [copied, setCopied] = useState(false) const [previewActive, setPreviewActive] = useState({}) const [openDropdown, setOpenDropdown] = useState(null)