From f9fe12bff3ff203edae64a6eba1ad95dc4f43645 Mon Sep 17 00:00:00 2001 From: Quynn Bell Date: Mon, 12 Oct 2026 10:33:10 -0700 Subject: [PATCH 1/7] feat(header): add initial structure for WordPress-style header navigation modules - Add base.nav, a nav bar that collapses to a dropdown or overlay below a chosen width - Add base.nav-item, a menu entry that nests to build submenus - Let a menu item be a link or a static label that toggles its submenu - Add the module contract fields they rely on: instanceCss, canvasCss, canvasViewportWidth, number presets and select icons - Header navigation: this is the core feature, and every later commit on the branch builds on it --- src/core/module-engine/propertySchema.ts | 8 + src/core/module-engine/types.ts | 28 ++ src/modules/base/index.ts | 4 + src/modules/base/nav-item/NavItemEditor.tsx | 62 +++++ .../nav-item/__tests__/navItem.render.test.ts | 248 ++++++++++++++++++ src/modules/base/nav-item/content.ts | 24 ++ src/modules/base/nav-item/icons.ts | 7 + src/modules/base/nav-item/index.ts | 97 +++++++ src/modules/base/nav-item/navItemCss.ts | 127 +++++++++ src/modules/base/nav-item/navItemRuntimeJs.ts | 51 ++++ src/modules/base/nav-item/props.ts | 14 + src/modules/base/nav/NavEditor.tsx | 75 ++++++ .../base/nav/__tests__/nav.render.test.ts | 190 ++++++++++++++ src/modules/base/nav/icons.ts | 23 ++ src/modules/base/nav/index.ts | 139 ++++++++++ src/modules/base/nav/navCss.ts | 138 ++++++++++ src/modules/base/nav/navRuntimeJs.ts | 54 ++++ src/modules/base/nav/props.ts | 60 +++++ src/modules/base/shared/navCollapse.ts | 29 ++ 19 files changed, 1378 insertions(+) create mode 100644 src/modules/base/nav-item/NavItemEditor.tsx create mode 100644 src/modules/base/nav-item/__tests__/navItem.render.test.ts create mode 100644 src/modules/base/nav-item/content.ts create mode 100644 src/modules/base/nav-item/icons.ts create mode 100644 src/modules/base/nav-item/index.ts create mode 100644 src/modules/base/nav-item/navItemCss.ts create mode 100644 src/modules/base/nav-item/navItemRuntimeJs.ts create mode 100644 src/modules/base/nav-item/props.ts create mode 100644 src/modules/base/nav/NavEditor.tsx create mode 100644 src/modules/base/nav/__tests__/nav.render.test.ts create mode 100644 src/modules/base/nav/icons.ts create mode 100644 src/modules/base/nav/index.ts create mode 100644 src/modules/base/nav/navCss.ts create mode 100644 src/modules/base/nav/navRuntimeJs.ts create mode 100644 src/modules/base/nav/props.ts create mode 100644 src/modules/base/shared/navCollapse.ts diff --git a/src/core/module-engine/propertySchema.ts b/src/core/module-engine/propertySchema.ts index c71cc6947..682a0ab9f 100644 --- a/src/core/module-engine/propertySchema.ts +++ b/src/core/module-engine/propertySchema.ts @@ -80,6 +80,12 @@ const PropertyControlOptionSchema = Type.Object( { label: Type.String({ minLength: 1 }), value: Type.Unknown(), + /** + * Inline SVG markup for this option. When any option of a `select` has + * one, the control renders as an icon button row with `label` as each + * button's tooltip and accessible name. Sanitised with `sanitizeSvg`. + */ + iconSvg: Type.Optional(Type.String()), }, { additionalProperties: false }, ) @@ -111,6 +117,8 @@ export const PropertyControlSchema = Type.Recursive((Self) => Type.Union([ max: Type.Optional(Type.Number()), step: Type.Optional(Type.Number()), unit: Type.Optional(Type.String()), + /** Suggested values shown as buttons under the field. Any number is still accepted. */ + presets: Type.Optional(Type.Array(Type.Number())), }, { additionalProperties: false }, ), diff --git a/src/core/module-engine/types.ts b/src/core/module-engine/types.ts index f99294fea..405d5faac 100644 --- a/src/core/module-engine/types.ts +++ b/src/core/module-engine/types.ts @@ -85,6 +85,12 @@ export interface RenderOutput { * The publisher deduplicates across all instances (one CSS block per module type). */ css?: string + /** + * Optional CSS that depends on this instance's props (e.g. a media query at + * an author-chosen width). Deduplicated by content, not by module type. + * Prop-independent rules belong in `css`. + */ + instanceCss?: string /** * Optional vanilla-JS runtime for this module TYPE, deduplicated per * moduleId exactly like `css` and served as an external per-module asset @@ -158,6 +164,13 @@ export interface ModuleComponentProps< * See `inlineEditableElementProps` in `@modules/base/shared/inlineText`. */ inlineEdit?: InlineEditBinding + /** + * Pixel width of the breakpoint frame this component renders in (e.g. 375 + * for mobile), so it can mimic responsive behavior. `undefined` when the + * render path does not provide it (e.g. sandboxed plugin modules, agent + * snapshot rendering). + */ + canvasViewportWidth?: number } /** @@ -399,6 +412,21 @@ export interface ModuleDefinition< */ component: ComponentType> + /** + * Optional stylesheet for `component`. The canvas never loads + * `render().css`, so a module that wants its published rules in the canvas + * repeats them here. + * + * Injected into every canvas frame after the reset and framework CSS and + * before class and user CSS, the same order the publisher uses. Use the + * published selectors so author classes override it as they do when + * published. + * + * Trusted modules only. A plugin module's component renders in a sandbox + * frame that this stylesheet never reaches. + */ + canvasCss?: string + /** * Optional dependency-backed editor runtime. When present, the canvas renders * this module in a sandboxed iframe with an import map built from the module's diff --git a/src/modules/base/index.ts b/src/modules/base/index.ts index 2d8ceed63..5eb3b98f0 100644 --- a/src/modules/base/index.ts +++ b/src/modules/base/index.ts @@ -31,6 +31,10 @@ import './svg' import './button' import './link' +// Navigation modules +import './nav' +import './nav-item' + // Form modules import './forms' diff --git a/src/modules/base/nav-item/NavItemEditor.tsx b/src/modules/base/nav-item/NavItemEditor.tsx new file mode 100644 index 000000000..9bfc2fcd6 --- /dev/null +++ b/src/modules/base/nav-item/NavItemEditor.tsx @@ -0,0 +1,62 @@ +/** + * base.nav-item editor preview component. + * + * Component-only file so React Fast Refresh can hot-patch edits without + * re-running module registration. + * + * Renders the same elements and classes as render(). Styling comes from the + * module's `canvasCss` (NAV_ITEM_CANVAS_CSS). A submenu opens while this item + * or a node inside it is selected. The toggle buttons do nothing, because + * module JS never runs in the canvas. + */ +import React from 'react' +import type { ModuleComponentProps } from '@core/module-engine' +import { cn } from '@ui/cn' +import { anchorRel } from '@modules/base/shared/anchorTarget' +import { htmlAttributesForReact } from '@core/htmlAttributes' +import { navItemHasSubmenu, navItemIsLink } from './content' +import { NAV_ITEM_CARET_SVG } from './icons' +import type { NavItemStoredProps } from './props' + +const caret = ( +