Editor Popover Pattern

How complex property editors (Action, Animations, Conditions, Data Attributes, Bundles, Icon picker) live in the toolbar without bloating the inline accordion: each one shows a chip in the section header that opens a draggable FloatingPa…

How complex property editors (Action, Animations, Conditions, Data Attributes, Bundles, Icon picker) live in the toolbar without bloating the inline accordion: each one shows a chip in the section header that opens a draggable FloatingPanel with the full editor inside.

This doc covers:

  1. The trigger ↔ panel split (and why it exists for HMR isolation)
  2. The SearchableMenuPopover primitive (Combobox in a Popover)
  3. The popover-mode property contract — how a custom input opts into the chip + FloatingPanel UX
  4. The PopoverOpenRequestAtom bus — how +-clicks and section-title clicks open the panel
  5. PropertySection popover-only behavior — header-only, no body, no toggle
  6. The custom-input registry — caching, lazy-loading, Suspense fallbacks

1. Trigger ↔ panel split

Every popover-mode component is split into two modules:

ActionInputPopover.tsx   ← thin trigger, always imported
ActionPanel.tsx          ← FloatingPanel + heavy body, lazy-loaded on first open

The thin trigger:

  • Reads the current value via useNode
  • Renders a chip (when has-value) or a small + icon (when empty)
  • Owns local open state + position calc
  • Lazy-imports the panel: const ActionPanel = lazy(() => import("./ActionPanel"))

The heavy panel module:

  • Imports FloatingPanel (drag/resize/focus-trap hooks)
  • Imports the full editor body (e.g. flat ActionInput, ConditionsInput)
  • Receives initialPosition, onClose, etc. as props
  • Mounts inside <Suspense fallback={null}>

Why splitFloatingPanel and the editor bodies pull in a lot of code (useDraggableWindow, useResizable, useFocusTrap, PropertyRenderer for bundles, etc.). Editing any of those files would otherwise ripple HMR through every popover trigger that imports them — and through the entire toolbar tree above. The split keeps those modules out of the import graph until the user actually opens the panel; HMR edits to the heavy modules only re-evaluate inside the lazy chunk.

Same trick is applied to:

Trigger moduleLazy panel module
chrome/toolbar/inputs/advanced/AnimationsInputPopover.tsxchrome/toolbar/inputs/advanced/AnimationsPanel.tsx
chrome/toolbar/inputs/advanced/ConditionChipRow.tsxchrome/toolbar/inputs/advanced/ConditionEditorPanel.tsx (one chip per array entry — see §8 list-style sections)
chrome/toolbar/inputs/action/ActionChipRow.tsxchrome/toolbar/inputs/action/ActionEditorPanel.tsx (one chip per actions[] entry — see §8 list-style sections)
chrome/toolbar/inputs/advanced/DataAttributesPopover.tsxchrome/toolbar/inputs/advanced/DataAttributesPanel.tsx
chrome/toolbar/inputs/bundle/BundleRow.tsxchrome/toolbar/inputs/bundle/BundleRowPanel.tsx
chrome/toolbar/inputs/color/GradientInputPopover.tsxchrome/toolbar/inputs/color/GradientPanel.tsx
chrome/toolbar/inputs/color/ColorInputPopover.tsxchrome/toolbar/inputs/color/ColorPanel.tsx (FloatingPanel) → ColorPanelBody.tsx (SketchPicker + hex/eyedropper + reused palette / special / recent / tailwind grids from chrome/toolbar/dialogs/colorPickerSections.tsx). Spawns a portal'd CreateTokenDialog anchored to panel.right + 8 when "+ New Token" is clicked. Dispatched directly from PropertyRenderer's case "color": — NOT registered in customInputs.tsx / popoverModeRegistry.ts because color sections own their +Add flow via the existing tailwind-class default. The original ColorInput.tsx (inline swatch row) is unchanged and still imported directly by inline TipTap, top-of-canvas tools, gradient stops, pattern body, icon color, button/divider color.
chrome/toolbar/inputs/advanced/ComponentImportExportPopover.tsxchrome/toolbar/inputs/advanced/ComponentImportExportPanel.tsx (FloatingPanel hosting the original ComponentImportExport body unchanged). Popover-only section: trigger is an invisible 0-size <span ref={triggerRef} class="block size-0"> anchor at the right edge of the section header — section title click dispatches via PopoverOpenRequestAtom, and computePosition reads the anchor's rect to dock the panel next to the section. No node value to read (always-actionable), no chip/clear UI. Listed in POPOVER_MODE + customInputs.tsx (chip skeleton).
chrome/toolbar/inputs/advanced/NodeAiContextInputPopover.tsxchrome/toolbar/inputs/advanced/NodeAiContextInputPanel.tsx. Same popover-only-section pattern as ImportExport (invisible anchor + title-click open).
chrome/toolbar/inputs/advanced/ContainerOverflowSectionPopover.tsxchrome/toolbar/inputs/advanced/ContainerOverflowSectionPanel.tsx (FloatingPanel hosting the existing mainTabs/ContainerOverflowSection body unchanged). Lives inside the multi-property alignment section (Layout tab) — NOT popover-only — so the trigger is a visible + icon. showWhen: props._craftName === "Container" keeps it hidden for non-Container nodes; sortOrder: 900 puts it at the bottom of Layout. The standalone overflow-scroll section was removed from sectionDefs.ts.
chrome/toolbar/inputs/advanced/ConditionsAddPicker.tsx(no FloatingPanel — popover-mode header + for the list-style Conditions section; opens a SearchableMenuPopover of condition types and appends to props.conditions. Body chips have their own ConditionEditorPanel per row. See §8.)
chrome/toolbar/inputs/action/ActionsAddPicker.tsx(no FloatingPanel — popover-mode header + for the list-style Action section; opens a SearchableMenuPopover of action types and appends to props.actions. Body chips have their own ActionEditorPanel per row. See §8.)
chrome/toolbar/inputs/action/HandlersAddPicker.tsx(no FloatingPanel — popover-mode header + for the list-style Handlers section; opens a SearchableMenuPopover of DOM event names and seeds props.handlers[event] = "". Events already in use are filtered out. Body chips (HandlerChipRow) open HandlerEditorPanel. See §8.)
chrome/toolbar/inputs/action/HandlerChipRow.tsxchrome/toolbar/inputs/action/HandlerEditorPanel.tsx (one chip per props.handlers entry — event dropdown + CodeMirror JS editor; see §8 list-style sections)
chrome/toolbar/inputs/modifiers/ModifiersAddPicker.tsxchrome/toolbar/inputs/modifiers/ModifiersPickerPanel.tsx (library — categorized preview-rich modifier picker with hover-preview eye on each chip) AND chrome/toolbar/inputs/modifiers/SaveModifierPanel.tsx (save — FloatingPanel listing every current className token as a removable chip + name/type inputs). The trigger is a popover-mode + that opens a SearchableMenuPopover with TWO actions ("Browse library" / "Save current styles as modifier"); selecting one routes to the corresponding panel. Bypass paths (chip click in the body chip-list, SessionAddedAtom, PopoverOpenRequestAtom) skip the menu and go straight to the library — only the manual + click shows the menu. See §8 for the list-style wiring + the divergent two-panel picker shape.
chrome/toolbar/dialogs/IconDialog/IconPickerPopover.tsxchrome/toolbar/dialogs/IconDialog/IconPickerPanel.tsx
chrome/toolbar/inputs/typography/TextStylePicker.tsxchrome/toolbar/inputs/typography/TextStylePickerPanel.tsx + TextStyleEditorPanel.tsx (two lazy panels behind one chip — picker → per-row Edit / "New Style" → editor; see text-styles)
chrome/toolbar/inputs/layout/LayoutPresetInput.tsxchrome/toolbar/inputs/layout/LayoutPresetPanel.tsx (FloatingPanel — flex-row / flex-col / grid presets in one flat grid + a Block tile + a Display section with the inline-block / inline-flex / inline-grid / inline / hidden variants). Ad-hoc — NOT registered in customInputs.tsx / popoverModeRegistry.ts. LayoutPresetInput is rendered directly by mainTabs (ContainerMainTab / DataMainTab) and LayoutPresetSlot, not as a unified-settings property def, so the registry plumbing (PopoverOpenRequestAtom, SessionAddedAtom, popoverModeRegistry) is not needed. The trigger owns its own useState(open) + triggerRef + computePosition — same trigger↔panel split for HMR isolation, but no cross-component open-request bus. The chip uses Chip (mode="popover") trailingExtras to dock a + Add container ToolbarIconButton (gated on hasContainerType) inside the same row — replaces the old standalone + Add Container dashed button. The chip row is wrapped in the canonical row-label pattern (<span className="w-20 shrink-0 truncate text-xs">Layout</span> left of the chip — same shape ActionChipRow / ConditionChipRow use) so it aligns with the other rows in the Layout / Alignment section (Direction, Flex, etc.).

Header-mounted modals (LayersDialog, ModifiersModal) follow the same shape but are lazy-loaded directly from Header.tsx.

Ad-hoc popovers (outside the unified-settings registry)

Triggers rendered directly by mainTabs / canvas tools / chrome (not by a registry property def) skip the registry plumbing. Use this shape when:

  • The popover lives inside a hand-written component, not a PropertyDef.input.type === "custom" registration.
  • No other surface needs to open it remotely (no + picker, no section-title click hand-off).
  • The chip's "current value" is derived from props the parent already reads via useNode.

What you keep: trigger ↔ panel split (HMR), Chip (mode="popover") (chip shape), FloatingPanel (lazy panel), computePosition reading triggerRef.current.getBoundingClientRect().

What you skip: customInputs.tsx, popoverModeRegistry.ts, PopoverOpenRequestAtom, SessionAddedAtom, popoverRequestKey/requestOpenPopover, def/PropertyInputProps typing, isActive gates, propertyHasValue value-gating.

Reference: LayoutPresetInput.tsx / LayoutPresetPanel.tsx. If a future surface needs to open the panel remotely (e.g. a "Pick layout" entry in a search palette), promote it into the registry — don't build a side-channel pubsub.

Row-label wrapping inside registry-driven sections

When an ad-hoc popover-chip lives inside a unified-settings section (Layout / Alignment / etc.), wrap the chip in the canonical row-label shape so it aligns with the registry-rendered rows above and below it (Direction, Flex, Gap, etc., which get their labels from ToolbarItem via def.label):

<div className="flex items-center gap-0.5">
  <span className="text-base-content w-20 shrink-0 truncate text-xs">Layout</span>
  <Chip mode="popover" ... />
</div>

Same shape ActionChipRow / ConditionChipRow use. Without it, the chip row sits flush-left while every sibling row has a w-20 label gutter — visually inconsistent. The label string should match def.label from the registry so it reads the same as the section's other rows.

Trailing slot for adjacent affordances

Chip (mode="popover") has a trailingExtras prop that renders inside the row frame, BEFORE the trailing X. Use it when you have a secondary action that's tied to the chip's subject (e.g. + Add container on a Layout chip, peek-target eye on a show-hide action chip) — not for unrelated controls. Each extra should be a size-5 icon button (ToolbarIconButton with a tooltip) so it lines up at the same 4px inset as the X.

<Chip mode="popover"
  ...
  trailingExtras={
    hasContainerType ? (
      <ToolbarIconButton ariaLabel="Add container" tooltip="Add container" onClick={addContainer}>
        <TbPlus className="size-3.5" aria-hidden />
      </ToolbarIconButton>
    ) : null
  }
/>

Standalone dashed +-style buttons sitting BELOW the chip (the old ToolbarDashedButton pattern) are a code-smell when the action belongs semantically to the chip — fold them into trailingExtras instead. Keeps the row-budget tight and the affordance discoverable next to its subject. Cap to 1–2 extras per chip; more belongs in the panel.


2. SearchableMenuPopover

packages/sdk/src/chrome/primitives/SearchableMenuPopover.tsx — the primitive for "click trigger → search → pick one" UX. Used by AccordionAddMenu for the + Add Property picker.

Composition:

  • Popover owns positioning (Headless UI floating-ui anchor)
  • Combobox inside the panel owns search/filter/keyboard nav
  • Free for the consumer: outside-click close, escape, focus management, arrow-key navigation

Why composed (vs Combobox alone): Combobox's own anchor is buggier than Popover's; the proven VariableInsertPanel pattern is Popover + Combobox. Body-as-render-prop receives close() from PopoverPanel so picking an item dismisses it.

<SearchableMenuPopover<PropertyDef>
  ref={popoverRef}
  trigger={<TbPlus className="size-3.5" aria-hidden />}
  triggerAriaLabel="Add property"
  onTriggerClick={ensureSectionOpen}
  items={available} // SearchableMenuItem<T>[]
  onSelect={onPick}
  emptyMessage="All properties added"
/>

SearchableMenuItem<T>:

{
  id: string;
  label: string;
  hint?: string;        // right-aligned secondary (groupLabel disambiguator)
  help?: string;        // tooltip / native title
  keywords?: string[];  // extra match terms
  data?: T;             // pass-through payload
}

Imperative open/close via ref:

interface SearchableMenuPopoverHandle {
  open: () => void;
  close: () => void;
}

Default styling matches the new editor radius scale: rounded-xl + shadow-xl + border-base-300 + p-1 panel, rounded-md items.


3. Popover-mode property contract

A property is "popover-mode" if its component string is registered in popoverModeRegistry.ts:

// packages/sdk/src/chrome/toolbar/inspector/popoverModeRegistry.ts
const POPOVER_MODE = new Set<string>([
  "ActionsAddPicker",
  "AnimationsInput",
  "ConditionsAddPicker",
  "ModifiersAddPicker",
  // ...
]);

To opt a new custom input into popover-mode:

  1. Build XInput.tsx (the flat editor body — what the panel hosts).
  2. Build XInputPopover.tsx (the thin trigger). Implement empty-state + icon and has-value chip + X clear. Listen to PopoverOpenRequestAtom for open requests. Auto-open from SessionAddedAtom (legacy +Add path).
  3. Build XPanel.tsx (the lazy chunk). FloatingPanel + XInput.
  4. Register in customInputs.tsx:
    XInput: chip(React.lazy(() => import("../inputs/.../XInputPopover"))),
    
  5. Add the component string to POPOVER_MODE in popoverModeRegistry.ts.
  6. Reference from a registry def with input: { type: "custom", component: "XInput" }.

The trigger contract:

  • Reads value from useNode. If empty → renders the small + icon trigger; else → renders the chip with summary text + TbX clear button.
  • Calls clearXxx() in the X handler — deletes the popover's owned props. Each component knows its own data shape (e.g. list-style chip rows like ActionChipRow / ConditionChipRow call onRemove on a single array entry; legacy single-prop popovers like the old AnimationsInput cleared their owned prop directly).
  • Listens to PopoverOpenRequestAtom for its key (${nodeId}:${defId}). Per-key version counter — every dispatch increments it so the trigger re-opens even after being closed.
  • Lazy-loads XPanel via React.lazy + <Suspense fallback={null}>.

4. PopoverOpenRequestAtom

packages/sdk/src/chrome/toolbar/inspector/popoverOpenRequestAtom.tsthe canonical bus for "open this trigger from somewhere else." Lets + clicks (AccordionAddMenu), section-title clicks (PropertySection), AND list-style header pickers (e.g. ConditionsAddPicker → ConditionsInput, see §8) open a popover when the trigger lives in another component / mounts later / is currently unmounted (closed accordion section).

⚠️ Do not invent a new module-level pubsub / Set<nodeId> / ref bag for this. Every cross-component open-request — including unmounted-receiver cases — must route through this atom so we keep one convention with a single set of guarantees (zedux subscription → re-render on remount, version counter for "open the same trigger twice in a row," (nodeId, defId) keying so multiple inspectors don't collide).

PopoverOpenRequestAtom: atom<Map<string, number>>
popoverRequestKey(nodeId, defId): string
requestOpenPopover(current, setRequests, nodeId, defId): void

Why a counter and not a boolean: the same trigger may need to be re-opened many times in a session. A simple "is open" set would only fire once per key. The counter lets every dispatch fire even if the value is unchanged — popover triggers track lastRequestVersion in a ref and re-open whenever the stored version differs.

Trigger-side pattern (every popover-mode component does this):

const popoverRequests = useAtomValue(PopoverOpenRequestAtom);
// MUST init to 0, not to the current version. If the dispatcher fired while
// this trigger was unmounted (closed accordion section, lazy chunk not yet
// loaded, etc.), the very first version we observe on mount IS the bump that
// has to fire — initializing to it would make the equality check bail and
// the popover would silently fail to open.
const lastRequestVersion = useRef(0);
useEffect(() => {
  if (!def) return;
  const version = popoverRequests.get(popoverRequestKey(id, def.id)) || 0;
  if (version === 0 || version === lastRequestVersion.current) return;
  lastRequestVersion.current = version;
  requestAnimationFrame(() => {
    setInitialPos(computePosition());
    setOpen(true);
  });
}, [popoverRequests, id, def?.id]);

Dispatcher-side: AccordionAddMenu and PropertySection both call requestOpenPopover(...). List-style header pickers (§8) do too — for "open my body's tail chip after I append" — keyed by the body's def id, not the picker's.


5. PropertySection popover-only behavior

When a section's single property is a popover-mode custom, the section degenerates to a header-only row:

  • collapsible={false}managed=false → AccordionContext atom is bypassed entirely. Persisted Animation: true from a prior session can no longer leak in.
  • defaultOpen={false}localIsOpen=false → ToolbarSection's body block (enabled && !disabled && isOpen) never renders.
  • className="cursor-pointer!" → restores the pointer cursor (collapsible=false drops it by default).
  • onClick always preventDefault()s and calls addMenuRef.current?.open() for popover-only sections — title click dispatches the open-request.
  • The popover-mode prop is filtered OUT of the body (the trigger is rendered in the header by AccordionAddMenu), so no empty body row.
// PropertySection.tsx
const isPopoverOnlySection =
  properties.length === 1 &&
  properties[0].input.type === "custom" &&
  isPopoverModeComponent(properties[0].input.component);

// Mixed pattern (Conditions): one non-pinned popover-mode picker alongside
// pinned body props. AccordionAddMenu's `sectionPopoverProp` mounts the
// picker in the section header — without filtering it out of the body it
// would render twice (duplicate `+`) AND keep `noVisibleContent` false
// even when no real body content exists.
const nonPinnedProps = properties.filter(p => !p.pinned);
const headerMountsPopoverPicker =
  !isPopoverOnlySection &&
  nonPinnedProps.length === 1 &&
  nonPinnedProps[0].input.type === "custom" &&
  isPopoverModeComponent(nonPinnedProps[0].input.component);

const filterPopoverModeProps = (p: PropertyDef) =>
  !(p.input.type === "custom" && isPopoverModeComponent(p.input.component));

const shouldFilterPopover = isPopoverOnlySection || headerMountsPopoverPicker;
const visibleMain = shouldFilterPopover ? main.filter(filterPopoverModeProps) : main;
const visibleAdded = shouldFilterPopover ? added.filter(filterPopoverModeProps) : added;
// Use post-filter counts so a header-mounted popover picker doesn't
// count as body content. Otherwise the empty-state `+`-on-title click
// handler wouldn't fire and the section would expand into a blank body.
const noVisibleContent = visibleMain.length === 0 && visibleAdded.length === 0;

Multi-popover-mode sections — when a section stacks multiple popover-mode rows under one header (currently used by Effects for animation / scroll-effect / transition / transform / filter / backdrop), isPopoverOnlySection is false and the section renders as a regular accordion: standard + button in the header (via AccordionAddMenu falling through to its SearchableMenuPopover), one chip per active row in the body. See §7 Multi-row popover-mode sections below.

List-style sections — when a section's body is one pinned chip-list backed by an array prop (e.g. Conditions with props.conditions: Condition[]), pair it with a non-pinned popover-mode …AddPicker so AccordionAddMenu's single-popover path mounts the + in the title row, and gate the pinned row's body visibility with def.isActive so the section collapses back to "header-only with +" when the array is empty. See §8 List-style sections below.

isActive on pinned non-popover props

A pinned non-popover-mode property normally always renders (the row IS the section content — Permissions / Import-Export / AI Context). When that's wrong — the body should hide once the underlying value is empty (an array of zero items, an object with no keys, etc.) — set isActive(className, props, ctx?): boolean on the def. PropertySection's main/candidates split honors it:

// PropertySection.tsx — pinned split with isActive gate
for (const p of visible) {
  if (p.pinned) {
    if (p.isActive && !p.isActive(className, componentProps, activeCtx)) continue;
    mainProps.push(p);
  } else {
    rest.push(p);
  }
}

When isActive returns false, the prop is excluded from main, doesn't reach PropertyRow, and (combined with the post-filter noVisibleContent above) the section falls back to the empty header-only +-to-add state. Used by Conditions to hide the chip-list body when props.conditions is [].

ctx — registry-aware isActive

The third arg is { query, nodeId } | undefinedquery is the CraftJS query from useEditor(), nodeId is the current node id. PropertySection and AccordionAddMenu both build a memoized activeCtx and pass it to every isActive call so registry-aware defs can reach state that lives outside (className, props).

Most defs ignore ctx and just look at props (Conditions: props.conditions.length > 0; Action: actions[].length > 0 || handlers). The Modifiers def needs it because "is any modifier active" can mean "any className token matches a class owned by a registered modifier" — and the modifier registry lives at node.data.type.craft.toolbar.modifiers (built-ins) plus ROOT.props.modifiers[<typeName>] (site-level custom). Without ctx, a node with a modifier class baked into its className via a template / Class Search would either:

  • be invisible to the body's chip-list (no chip rendered → user can't see / remove it), OR
  • keep the section expanded forever (because pinning lacks an off-switch).
// registry/properties/modifiers.ts — modifier def isActive
isActive: (className, props, ctx) => {
  const tracked = props?.root?.activeModifiers;
  if (Array.isArray(tracked) && tracked.length > 0) return true;
  if (!ctx) return false;
  const tokens = collectModifierClassTokens(ctx.query, ctx.nodeId);
  for (const t of className.split(/\s+/)) {
    if (t && tokens.has(t)) return true;
  }
  return false;
},

Rule of thumb: if your isActive answer can be derived from (className, props) alone, leave ctx out. Reach for ctx only when "active" depends on the node-type's own registry or on data that doesn't live on this node's props.

Trigger shape for popover-only vs multi-property sections

Two conventions for what the trigger renders. Pick based on where the popover-mode prop lives:

  • Popover-only section (ImportExport, AI Context, single popover-mode prop in the section): the section title IS the click target — title click fires PopoverOpenRequestAtom which the trigger listens for. The trigger itself only needs to (a) be mounted to receive the request, and (b) provide a stable rect for computePosition to anchor the FloatingPanel near. Render an invisible 0-size anchor:

    <span ref={triggerRef as any} aria-hidden className="block size-0" />
    

    Don't render a visible + icon — the section header is already the affordance, a duplicate + looks like noise.

  • Multi-property section (Overflow inside Layout): the trigger is one of many siblings, so render a visible + icon that the user can click directly:

    <button
      ref={triggerRef}
      className="text-neutral-content hover:text-base-content hover:bg-base-200 flex size-4 shrink-0 items-center justify-center rounded-md opacity-70 transition-[color,background-color,opacity] hover:opacity-100"
    >
      <TbPlus className="size-3.5" aria-hidden />
    </button>
    

Cursor on popover-only section headers

ToolbarSection historically hardcoded cursor-default on its inner titleHitClasses role="button" div when collapsible=false — that overrode the cursor-pointer! PropertySection passes via className, so popover-only headers visually read as un-clickable. Fixed by dropping the inner override so the parent's cursor inherits down. If you add a new non-collapsible section that genuinely shouldn't show a pointer, set cursor-default explicitly via the consumer-passed className rather than re-introducing it inside ToolbarSection.

Pin dock exclusion

Popover-only sections must be added to NON_PINNABLE in chrome/toolbar/inspector/inspectorPin/InspectorPinContext.tsx — their body lives inside a FloatingPanel so there's nothing meaningful to portal into the inspector pin dock. Currently excluded: component, modifiers, advanced-settings, import-export, ai-context, permissions. Add new popover-only section IDs to the same set.

AccordionAddMenu's +/header slot for popover-only sections:

if (sectionPopoverProp && sectionPopoverProp.input.type === "custom") {
  // Render the popover trigger directly in the section header — single
  // source of truth for the chip + panel + open-request listener.
  const PopoverComponent = resolveCustomInput(sectionPopoverProp.input.component);
  return <PopoverComponent def={sectionPopoverProp} />;
}

AccordionAddMenu's imperative open() (called by PropertySection on title-click):

open: () => {
  // Popover-mode single-prop section → expand AND dispatch open-request.
  // The trigger lives inside the section body for non-popover-only sections,
  // so the section MUST be expanded before the request fires (otherwise the
  // listener isn't mounted).
  if (sectionPopoverOnly) {
    ensureSectionOpen();
    requestOpenPopover(popoverRequests, setPopoverRequests, id, def.id);
    return;
  }
  popoverRef.current?.open();
};

PropertyRow visibility

Popover-mode custom inputs default to always render — they own their own empty-state (chip hidden when no value) and need to be mounted to receive open-requests. Generic propertyHasValue can't see their internal data shape (e.g. actions[] array vs a class named "action"), so a value-gated render would never trigger.

// PropertyRenderer.tsx
const isPopoverModeCustom =
  def.input.type === "custom" && isPopoverModeComponent(def.input.component);
if (isPopoverModeCustom) return <PropertyRenderer def={def} index={index} />;
// ... fallback hasValue / sessionAdded / toolbarOrder / open-request gate

Opt-in value-gating via def.isActive — popover-mode rows that share a section (Effects builder) need the OPPOSITE: only render in body when truly active. Set isActive(className, props): boolean on the PropertyDef and PropertySection will gate that row by isActive() || toolbarOrder || sessionAdded instead of always-rendering. AccordionAddMenu's + picker uses the same hook to hide already-active items so the user can't double-add.


6. Custom-input registry (customInputs.tsx)

String-keyed registry mapping component names to lazy React components + Suspense fallback skeletons. Two non-obvious things:

Stable wrapped reference (cache)

const wrappedCache = new Map<string, ComponentType<PropertyInputProps>>();

export function resolveCustomInput(ref) {
  const cached = wrappedCache.get(ref);
  if (cached) return cached;
  // ... build new Wrapped + Suspense, cache it, return
}

Without the cache, every PropertyRenderer re-render handed React a new component reference → unmount + remount → killed useState(open=true) in popover triggers. Stable reference = stable instance.

registerCustomInput() invalidates the entry's cache slot when a host re-registers a key.

Per-entry Suspense fallback

const lazyMap: Record<string, LazyEntry> = {
  XxxInput: row(React.lazy(() => import("..."))), // ghost label + input row
  XxxBlock: block(React.lazy(() => import("..."))), // taller block
  ActionInput: chip(React.lazy(() => import("..."))), // h-8 chip
};

row/block/chip helpers wrap with RowSkeleton/BlockSkeleton/ChipSkeleton defaults so the lazy load doesn't flash a 0-height gap. Hosts can pass fallback: <CustomNode /> via registerCustomInput(key, component, { fallback }).


7. Multi-row popover-mode sections

Sometimes you want multiple popover-mode rows stacked under one accordion (each its own chip + lazy panel). The unified Effects section (Animation / Scroll Effect / Transition / Transform / Filter / Backdrop) is the canonical example. Same building blocks as a single popover-mode prop, but with three small extensions to make the standard + Add flow work for several siblings at once:

Wiring summary

  1. Register N popover-mode property defs in the section, each with input: { type: "custom", component: "<SharedComponent>" }. They can share one trigger component that reads def.id and dispatches to per-id behavior — see EffectRowInputPopover.tsx + effectTypes.tsx in registry/properties/effects-builder/.
  2. Add the shared component string to popoverModeRegistry.ts and to customInputs.tsx (chip(...) fallback).
  3. Each def declares isActive(className, props): boolean. PropertySection value-gates the row by it (only ACTIVE rows render in body); AccordionAddMenu excludes active rows from the + picker so the user can't double-add. Without isActive, popover-mode rows fall back to always-rendering — which is what you want for single-prop popover-only sections, but produces empty rows in a stacked layout.

What changes vs single-prop popover-only sections

  • PropertySection.isPopoverOnlySection is false (it requires properties.length === 1) → section renders as a regular accordion (collapsible=true, normal toggle behavior).
  • The section header's + falls through AccordionAddMenu to its SearchableMenuPopover because there are multiple addable props — picker shows the addable rows; pick one → sessionAdded fires → trigger mounts → auto-opens its panel via the standard pattern (§3 + §4).
  • Each row's body lives inside the accordion (one Chip (mode="popover") per active row, <label> | <chip> shape — label outside, chip flex-1 — matching PatternInputPopover / GradientInputPopover).
  • Empty section (no active rows, none session-added) → all rows return nullnoVisibleContent === true → ToolbarSection forces enabled={false} and the body collapses to header-only. Matches every other section in the toolbar.

Storage stays split

The shared trigger reads/writes through a per-id reducer (type.isActive / type.summary / type.clear / type.EditorPanel) so storage stays where it already lives — root.animation* for animation, container props for scroll-effect, className tokens for transition/transform/filter/backdrop — no migration. The builder is a view, not a new schema.

AccordionAddMenu gating reference

// AccordionAddMenu.tsx — picker filter
.filter(p => !p.showWhen || p.showWhen(className, showWhenProps))
.filter(p => !orderSet.has(p.id))
.filter(p => {
  if (p.isActive) return !p.isActive(className, componentProps);
  return !propertyHasValue(p, className, componentProps, view, classDark);
})

showWhen is a coarse gate (e.g. scroll-effect = Container only — synthesized _craftName lives on showWhenProps). isActive is the fine value gate. A row missing isActive falls through to propertyHasValue, preserving existing behavior for non-shared popover-mode props.


8. List-style sections

When a section's body is a dynamic-length list of items (e.g. Conditions with props.conditions: Condition[], Action with props.actions: NodeAction[], Modifiers with className tokens + props.root.activeModifiers), the right shape is:

  • Body = one pinned non-popover-mode custom input that renders one Chip (mode="popover") per active item.
  • Header + = a separate non-pinned popover-mode picker.

Three implementations today — Conditions, Action, Modifiers — all share the same overall structure but differ in two axes: (a) what the + opens, and (b) where "is this active" is computed.

Section+ opens"Active" check (body's isActive)Per-chip click
ConditionsSearchableMenuPopover of condition types → appends to props.conditions[]props.conditions.length > 0 (props only)Opens own ConditionEditorPanel
ActionSearchableMenuPopover of action types → appends to props.action[]action[].length > 0 (props only)Opens own ActionEditorPanel
HandlersSearchableMenuPopover of DOM event names → seeds props.handlers[event] = "" (events in use are filtered out)Object.keys(props.handlers).length > 0 (props only)Opens own HandlerEditorPanel (event dropdown + CodeMirror JS editor)
ModifiersSearchableMenuPopover with TWO actions ("Browse library", "Save current styles as modifier") → routes to ModifiersPickerPanel (library) or SaveModifierPanel (save). Bypass paths (chip click, SessionAddedAtom, PopoverOpenRequestAtom) skip the menu and open the library directly.props.root.activeModifiers.length > 0 || any className token matches a registered modifier class — needs the registry, so reaches in via ctx.query (see §5 "ctx — registry-aware isActive").Dispatches PopoverOpenRequestAtom for the picker's def — there's no per-chip editor; the picker IS the editor. The chip's X removes the modifier.

The closed-section auto-open hand-off rides on the existing PopoverOpenRequestAtom from §4 — do not invent a new module-level pubsub for this.

Picker variants

Most list-style sections want the + to be a flat list of types you append (Conditions, Action). When the + needs to branch to MULTIPLE panel types (Modifiers: library vs save), put a SearchableMenuPopover at the trigger AND keep separate FloatingPanels behind it — same as Color's + New Token spawning CreateTokenDialog next to ColorPanel. The bypass paths (chip click / open-request / session-add) should skip the menu and route to whichever panel is "the default action" — for Modifiers that's the library; otherwise users hitting a chip would see a menu instead of the editor and find it weird.

Sibling list-style sections (Action + Handlers)

Action (declarative typed actions — props.action[]) and Handlers (raw JS escape hatch — props.handlers map) are two different data shapes that both fire on DOM events. They live as sibling sections in the Component tab — not stacked in one mixed-source body — because the mental model is different:

  • Action = no-code, picker UI, schema-typed (link, open-modal, show-hide, …).
  • Handler = power-user, raw JS string keyed by event name (onClick, onMouseEnter, …).

Mixing them in one section was confusing — empty action list still showed a "+ Add Handler" affordance, two mental models in one box. Splitting them keeps each section focused: empty Action section → header-only with +; same for Handlers. Each is its own pinned-body + popover-mode :add picker pair (mirrors Conditions exactly), with its own body def id ("action" and "handlers") on the canonical PopoverOpenRequestAtom bus.

Reference files (Handlers):

Per-chip behavior

If the items have per-item state (Conditions edits one condition's fields, Action edits one action's fields), each chip lazy-loads its OWN editor panel and click opens it. If the item is just on/off (Modifiers — a modifier is either applied or not, no per-item config), the chip click instead dispatches an open-request to the section's picker and the X removes the modifier. Skip the per-chip popover until you actually need per-item config — adding it preemptively is dead chrome.

Why this split (and not one popover-mode prop)

  • The header + and the body are TWO distinct rendering contexts. AccordionAddMenu mounts a popover-mode prop in the section title row via sectionPopoverProp; it is not also rendered in the body if headerMountsPopoverPicker matches (see §5). One prop can't be the trigger AND the body at the same time.
  • Per-item chips need their own panels (one editor per condition), so the body can't itself be popover-mode — it's a list, not a single trigger.
  • Pinning the body keeps it always-mountable so the chip-list renders the moment the array gains an entry; isActive then gates that pinned row OFF when the array is empty so the section returns to header-only "click + to add".

Wiring summary

  1. Two property defs in the section. Pinned body + non-pinned popover-mode picker:

    // registry/properties/<area>.ts
    {
      id: "conditions",            // body chip-list
      section: "conditions",
      input: { type: "custom", component: "ConditionsInput" },
      pinned: true,
      // (className, props, ctx?) — ctx only needed when "active" can't be
      // derived from props alone. See §5 "ctx — registry-aware isActive".
      isActive: (_cls, props) =>
        Array.isArray(props?.conditions) && props.conditions.length > 0,
    },
    {
      id: "conditions:add",        // header `+` picker
      section: "conditions",
      input: { type: "custom", component: "ConditionsAddPicker" },
    },
    

    The pinned body MUST set isActive so empty-array state collapses the section back to header-only (see §5 "isActive on pinned non-popover props").

  2. Register the picker in popoverModeRegistry.ts and customInputs.tsx. The body is a normal non-popover custom input (block(...) skeleton).

  3. Header picker (<Foo>AddPicker.tsx) — popover-mode component returning a SearchableMenuPopover with the available item types. On select:

    • setProp to append the new entry
    • Expand the section so the body can mount: accordionCtx?.setOpen(sectionTitle, true)
    • Dispatch an open-request to the body via requestOpenPopover(popoverRequests, setPopoverRequests, id, BODY_DEF_ID) (see hand-off below)
  4. Body (<Foo>Input.tsx) — reads the array, renders one <FooChipRow> per entry, returns null when the array is empty (the section is already in header-only mode at this point — body returning null is just defensive).

  5. Per-item chip (<FooChipRow>.tsx) — Chip (mode="popover") (icon + summary) + lazy-loaded <FooEditorPanel> for THIS item. Accepts autoOpen?: boolean and onAutoOpenConsumed?: () => void from the body.

  6. Per-item panel (<FooEditorPanel>.tsx) — FloatingPanel hosting the form fields for ONE item. The form fields can be extracted into a shared <FooFields> component reused by other surfaces (e.g. Conditions reuses ConditionFields in both ConditionEditorPanel and the AccessTab grouped UI).

Auto-open hand-off (closed-section case)

When the user clicks the header + while the section is collapsed, the body is unmounted, so a length-growth useEffect on the body never fires for that add — prevLengthRef initializes to the post-add length on remount, missing the bump. The fix rides on the same PopoverOpenRequestAtom from §4 that AccordionAddMenu and PropertySection already use — do NOT invent a new module-level pubsub.

Convention: the picker dispatches a request keyed by (nodeId, <bodyDefId>) (the body's def id, e.g. "conditions", NOT the picker's "conditions:add"). The body listens on its own def id.

// <Foo>AddPicker.tsx (header `+`)
const BODY_DEF_ID = "conditions"; // matches registry def id
const [popoverRequests, setPopoverRequests] = useAtomState(PopoverOpenRequestAtom);
const onPick = item => {
  setProp(p => {
    p[arrayKey] = [...(p[arrayKey] || []), defaultEntry(item)];
  });
  if (sectionTitle) accordionCtx?.setOpen(sectionTitle, true);
  requestOpenPopover(popoverRequests, setPopoverRequests, id, BODY_DEF_ID);
};
// <Foo>Input.tsx (body)
const BODY_DEF_ID = "conditions";
const popoverRequests = useAtomValue(PopoverOpenRequestAtom);
const requestVersion = popoverRequests.get(popoverRequestKey(id, BODY_DEF_ID)) || 0;

const prevLengthRef = useRef(list.length);
const [pendingOpenIdx, setPendingOpenIdx] = useState<number | null>(null);

// Path 1 — body was mounted while array grew (in-section "+" / sibling write)
useEffect(() => {
  if (list.length > prevLengthRef.current) setPendingOpenIdx(list.length - 1);
  prevLengthRef.current = list.length;
}, [list.length]);

// Path 2 — section was closed; body just remounted with the new entry already
// in `list`, so length growth was missed. Open whenever the request version
// advances, mirroring `EffectRowInputPopover.tsx` §4.
//
// Init to 0, NOT to the current `requestVersion`: if the picker dispatched
// while this body was unmounted, the very first version we observe on mount
// IS the bump we need to consume. Initializing to it would make the equality
// check bail and the auto-open would silently no-op.
const lastVersionRef = useRef(0);
useEffect(() => {
  if (requestVersion === 0 || requestVersion === lastVersionRef.current) return;
  lastVersionRef.current = requestVersion;
  if (list.length > 0) setPendingOpenIdx(list.length - 1);
}, [requestVersion, list.length]);

Both paths converge on a single pendingOpenIdx state that drives the chip's autoOpen. The chip clears the parent's pending state on consume so subsequent rerenders don't re-open.

Why this and not a custom Set<nodeId> module: every other "open my popover from somewhere else" surface in the editor (Effects rows, Action input, AccordionAddMenu single-option +, PropertySection title click) already routes through PopoverOpenRequestAtom. A side-channel splits the convention, has no zedux subscription so the body wouldn't re-render to consume it on remount-after-mount-after-add (you'd have to read it in useState initializer, which only fires once), and breaks the version-counter contract that makes "open the same trigger twice in a row" work.

What changes vs popover-only sections

  • isPopoverOnlySection is false (properties.length === 2).
  • headerMountsPopoverPicker is true (one non-pinned popover-mode prop alongside ≥1 pinned props) → shouldFilterPopover filters the picker out of the body so it only renders in the header.
  • noVisibleContent is computed from visibleMain.length === 0 && visibleAdded.length === 0 (post-filter), so the empty-state title-click handler still fires.
  • enabled / defaultOpen follow the standard non-popover-only path: section behaves like a regular accordion when populated.

Reference files

Conditions:

Action (mirrors Conditions):

Handlers (sibling of Action — raw JS event handlers):

Modifiers (registry-aware isActive; two-panel picker — library + save):

Shared bus:


Row frame & trailing chrome (Chip / ToolbarIconButton / InlineClearButton / ChevronTrigger)

Chip is the only way to render a bordered toolbar input row. It owns the entire shape — height, padding, border, radius, focus state, gap, trailing-chrome inset — so every row in the toolbar terminates at the same outer dimensions (h-8 content + 1px border = 34px outer, gap-1.5 px-1 interior, trailing size-5 icon at exactly 4px from the right border).

  • Chip (packages/sdk/src/chrome/primitives/Chip.tsx) — input-wrapper flex h-8 w-full items-center gap-1.5 px-1 text-xs. Pass the leading control as children (the control should use h-full so it fills the slot — never h-8 / min-h-8). Pass optional trailing buttons as trailing. open adds border-primary ring-ring/45 ring-1. onClick makes the whole row a click target.
  • ToolbarIconButton (packages/sdk/src/chrome/primitives/ToolbarIconButton.tsx) — canonical size-5 rounded icon button. Two variants: ghost (default — no hover bg, used inside row-frame trailing slots) and subtle (hover:bg-neutral ramp, used standalone next to inputs). Forwards a ref. Wires tooltip against the global RTT surface automatically. Use this for any small icon button in editor chrome — do not hand-roll size-5 ... text-neutral-content hover:text-base-content.
  • InlineClearButton (packages/sdk/src/chrome/primitives/InlineClearButton.tsx) — neutral × button. Default mode composes from ToolbarIconButton (ghost). floating variant is a separate shape (chip-style overlay over a media preview tile, with bg + border + shadow).
  • ChevronTrigger (packages/sdk/src/chrome/primitives/ChevronTrigger.tsx) — chevron button that rotates on open. Composes from ToolbarIconButton (ghost).

Do-nots (review-rejects)

Reviewers should reject any new editor code that:

  • Hand-rolls <div className="input-wrapper ..."> for a single-line input row. Use the frame.
  • Sets h-8, min-h-8, h-9, or any height on a row wrapper or its leading control.
  • Adds p-0!, pl-0!, pr-0!, gap-1, gap-2, or px-1.5 overrides on a row wrapper. The frame's spacing is canonical — overrides drift.
  • Adds a className prop to Chip. The frame intentionally does not take one.
  • Renders a chevron via <TbChevronDown> inside an ad-hoc <span class="-mr-1.5 opacity-50"> or <button class="p-1">. Use ChevronTrigger (or the size-5 slot pattern in ToolbarDropdown's trigger).
  • Hand-rolls size-5 ... text-neutral-content hover:text-base-content for any small icon button. Use ToolbarIconButton (variant: ghost inside row frames, subtle standalone).

Tiles (NOT rows)

Tiles are static visual surfaces — they're not bordered input rows and don't go through Chip. The shared static-preview shape (rounded box, neutral fill, clipped content) is the MiniPreviewTile primitive (packages/sdk/src/chrome/primitives/MiniPreviewTile.tsx) — sizes swatch (h-6 w-12) and video (aspect-video w-full), with bg / rounded / bordered / padded / relative props for the small variations between callsites. Used by PatternsDialogInput swatch, MediaInput aspect-video preview, MediaTab selected-media preview. Use this primitive for any new static preview surface — do not hand-roll a new bordered tile div.

The following are intentionally separate from MiniPreviewTile — they use their own ad-hoc structure for good reasons:

  • IconDialogInput trigger (h-12 w-[70px]) — square icon-preview tile that's also a button (passes a triggerClassName through to IconPickerPopover).
  • Chip preview variant (h-6 inner button) — full-bleed tile inside the row frame, used by Gradient.
  • GradientInput direction selectorToolbarSegmentedControl (bg-neutral rounded-md p-1 track) — has its own segmented-control surface.
  • ToolbarItem checkbox toggle row — borderless flex row with switch.
  • BackgroundFocalPointPicker canvas — interactive crosshair canvas (cursor-crosshair, click-handlers).
  • ColorPickerDialog swatchessize-5/7 interactive swatch grid with selection rings + hover scale.
  • Standalone <input class="input-plain"> outside a frame (dialog search bars, MediaManagerModal, MediaToolbar). The CSS h-8 rule on input-plain is for these standalone uses.

Multi-line input rows (textarea)

Chip from ToolbarStyle.tsx defaults to wrapping its children in Chip (single-line). For textarea, pass flexible to opt into a legacy auto-height div. Do not introduce new flexible callsites — anything single-line goes through the frame.

ToolbarDropdown and Headless UI listbox

ToolbarDropdown wraps its <ListboxButton> inside Chip and renders the chevron as a sibling size-5 slot in the frame's trailing position. Headless UI requires the listbox button itself to remain the click target, so the chevron is a non-interactive visual sibling — the button's whole label area stays clickable, the trailing chevron just signals open state.

Chip primitive (<Chip mode="popover">)

packages/sdk/src/chrome/primitives/Chip.tsx is the single source of truth for every sidebar row, including popover triggers. Do not hand-roll a new input-wrapper h-8 ... + X chip. If a new popover trigger needs a slightly different look, extend Chip (add a variant or slot) — do not fork it. See primitives/chip.md for the full API.

Popover-mode props (in addition to label / propKey / propType / etc. shared with input-mode)

interface ChipPopoverProps {
  mode: "popover";
  open?: boolean; // toggles border-primary + ring while panel is open
  onTriggerClick: () => void; // click on chip body — opens / toggles the panel
  onClear: () => void; // click on trailing X — clears the underlying value (session-aware)
  triggerAriaLabel: string;
  clearAriaLabel: string;
  leading?: ReactNode; // icon, swatch, or full-bleed preview (depending on variant)
  summary?: ReactNode; // text rendered next to leading (default variant only)
  variant?: "default" | "preview"; // default: icon + text; preview: leading fills the chip body
  previewOverlay?: ReactNode; // optional pill rendered on top of leading in preview mode
  trailingExtras?: ReactNode; // optional secondary affordances rendered INSIDE the frame before the X (e.g. show-hide chip's eye-toggle peek button). Keep them `size-5` to match the trailing column.
}

forwardRef lands on the inner trigger button so popover triggers can call triggerRef.current?.getBoundingClientRect() for panel positioning.

Variants

  • default — leading icon + summary text, side by side. Used by Action / Animations / Conditions chip rows / Data Attributes / Bundle.
  • previewleading is rendered as the full chip body (e.g. an absolute-positioned gradient swatch). previewOverlay paints a pill on top. Used by Gradient.

Styling

The summary span uses text-base-content so chip values read at the same weight as a regular input value (e.g. font-size showing 2X Large, action chip showing Home Page). The leading icon stays text-neutral-content for the standard glyph-dim convention. Don't override the summary color to text-neutral-content — the empty/placeholder feel comes from the summary text itself (Add…, Default), not from a tonal shift. The original chip rendered the summary muted; that was the bug fixed alongside the LinkInput rebuild.

Empty state

Chip (mode="popover") only renders the has-value chip. Empty states stay per-trigger because they vary:

  • Action / Animations — small 16×16 + icon (no chip frame); they sit in popover-only section headers and the section's own + is the entry point.
  • Conditions chip rows are NEVER empty — they only render once a condition has been appended via the header + picker (see §8). The header-side ConditionsAddPicker itself never renders a "has-value" chip; it's a SearchableMenuPopover whose visible affordance is just the + icon trigger.
  • Data Attributes — Add data-… text button.
  • Bundle / Gradient — Chip (mode="popover") with leading icon + a synthesized child-summary (or Add... when nothing is set yet). BundleRow reads each child's value via getPropFinalValue (class) or the raw prop (component / root), formats class tokens through formatTailwindDisplayLabel, joins up to 2 labels with " · ".

Wiring checklist (new popover trigger)

  1. Build XInput.tsx (the editor body).
  2. Build XPanel.tsx — wraps XInput in FloatingPanel (lazy-loaded by the trigger).
  3. Build XInputPopover.tsx:
    • Read value via useNode.
    • Decide isEmpty from your data shape.
    • Render the empty state per the section above.
    • Render the has-value chip via Chip (mode="popover") — never duplicate the chip JSX.
    • Listen to PopoverOpenRequestAtom (per-key version counter — see §4).
    • Listen to SessionAddedAtom for the legacy auto-open path.
    • Implement clearXxx() that resets every prop / class the popover owns.
    • Lazy-load XPanel via React.lazy + <Suspense fallback={null}>.
  4. Register in customInputs.tsx with chip(...) fallback.
  5. Add the component string to POPOVER_MODE in popoverModeRegistry.ts.
  6. Reference from a registry def with input: { type: "custom", component: "XInput" }.

FloatingPanel sizing — auto by default

FloatingPanel defaults to autoSize: true — width / height collapse to fit-content clamped by minWidth / maxWidth / minHeight / maxHeight, and the resize handles + persisted size are disabled. Right for the vast majority of toolbar / sidebar popovers (Calc, Condition / Action / Effect-row editors, DataAttributes, Bundle, AI Context, Container Overflow, Save Modifier, …): the form's content drives layout, the panel hugs it, overflow scrolls inside the existing AutoHideScrollbar body.

Don't pass defaultWidth / defaultHeight for autoSize panels. They become hint-only (used for initial position centering / dock-to-edge math) and are not enforced. Set minWidth / maxWidth / minHeight / maxHeight if the auto-size needs custom bounds; FloatingPanel's defaults (min 300×300, max 1200 × 95vh) work for almost everything.

Pass autoSize={false} to opt into a fixed pixel box with user-resize + persisted dims. Required when:

  • the panel is a large dialog with internal layout (SettingsShell, ModifiersModal, LayersDialog, the TextMainTab inline tiptap "Edit HTML" dialog),
  • the body is a grid whose column count depends on width (IconPickerPanel, PatternPanel, ColorPanel's SketchPicker, ModifiersPickerPanel),
  • the body is a multi-column form that needs space (TextStyleEditorPanel, TextStylePickerPanel, ComponentImportExportPanel's JSON view).

When autoSize: false, defaultWidth and defaultHeight are required and become the resize floor; the panel persists size to storageKey unless persistSize: false is passed.


Summary text per popover

Each popover writes a one-line summary for the chip:

ComponentEmpty1 itemMany
Action(chip hidden)Link → /home3 actions
Animations(chip hidden)Fade Up(single only)
Conditions (per chip)(row not rendered)URL ref = signupone chip row per condition; section header + picker never shows a summary chip
Data Attributessmall Add data-… button1 attribute4 attributes
Gradientcanonical icon + Add... chipfull-bleed gradient preview fills the chip body, direction arrow rendered as a small pill overlay(single gradient — chip is the preview)

Tap into your own data shape — keep it short, drop the chip if nothing's there.


Anchored list popovers — the ph-select-* chrome

For any inline picker that opens a portaled list (pages, anchors, action types, font sizes, etc.) don't hand-roll a createPortal + useLayoutEffect positioning + outside-click useEffect triplet. Use AnchoredPopover + the unified .ph-select-* classes from packages/sdk/src/css/editor-partials/dropdowns.css.

ClassRole
pagehub-sdk-root ph-select-contentOuter panel — border, bg, radius, shadow, fade keyframes via [data-state].
ph-select-groupSection header inside the panel (Pages, Anchors, Open, Commerce, …). Bold sentence-case.
ph-select-itemOption row — adds hover/focus/data-selected states. Add data-selected:bg-primary data-selected:text-primary-content inline if the trigger needs a strong "currently picked" highlight (matches ToolbarDropdown).

ToolbarDropdown is the canonical consumer (it parses native <optgroup> children into ph-select-group headers automatically). For one-off list popovers that aren't a Listbox — anchored to a combo input, multiple sources, etc. — anchor an AnchoredPopover to the row wrapper, give it matchAnchorWidth={{ min, max }}, set className="pagehub-sdk-root ph-select-content max-h-64 overflow-y-auto", and emit <div className="ph-select-group …"> headers between option groups. Reference: LinkInput.tsx — pages + anchors picker with grouped headers, mirrors the Action picker's "Open / Commerce" sections.

AnchoredPopover automatically stamps data-ph-popover on its portaled root, so it's already on the FloatingPanel skip-list (next section) — picking an option inside a popover-mode editor panel won't dismiss the parent panel.


FloatingPanel outside-click — descendant portal self-registration

FloatingPanel's outside-click handler at FloatingPanel.tsx runs in capture phase on document.pointerdown (capture, not bubble — Headless UI synchronously detaches a Listbox's portal subtree on option click, so by bubble phase target.closest() walks an orphaned tree and misses ancestor selectors). It dismisses the panel unless the click target is inside the panel itself, inside a portal that has self-registered as a descendant, or inside a third-party portal we can't make self-register.

How it works

Each open FloatingPanel exposes a FloatingPanelContext whose registerPortal(node) → unregister lets descendant portals mark themselves as "inside" for dismiss purposes. The handler skips:

SourceHow it's recognized
The panel itselfwindowRef.current.contains(target)
Self-registered descendant portalsMembership in the panel's portal Set<HTMLElement> (populated via context)
[data-headlessui-portal]Third-party — Headless UI Listbox / Combobox / Dialog / Menu
[data-rtt-tooltip]Third-party — react-tooltip surfaces

Self-registration is wired automatically by:

  • AnchoredPopover — calls useFloatingPanelPortalRegister() and registers its floating root on mount.
  • Nested FloatingPanel — registers its own root with the parent panel's context, so panel-on-panel works without any manual marker.

Hand-rolled portals inside a FloatingPanel

For the rare case where you must hand-roll a portal (e.g. an absolute-positioned dialog with custom layout that doesn't fit AnchoredPopover), import the hook from chrome/floating/FloatingPanel:

import { useRegisterFloatingPanelPortal } from "@/chrome/floating/FloatingPanel";

function MyPortal({ children }) {
  const ref = useRef<HTMLDivElement>(null);
  useRegisterFloatingPanelPortal(ref);
  return createPortal(<div ref={ref}>{children}</div>, document.body);
}

The hook no-ops outside a FloatingPanel, so the same component works in any context. Reference callsites: ColorPickerPortal in ColorInput.tsx, CreateTokenDialogPortal in DesignSystemPalette.tsx, the panel ref in PatternsDialogInput.tsx.

Why self-registration over a selector skip-list

Previously the handler matched a fixed list of selectors ([data-ph-popover], .ph-select-content, [role="dialog"], [role="listbox"], [role="menu"], [data-floating-allow], plus the two third-party ones). Every new portaled overlay had to remember to stamp a marker, and [data-floating-allow] existed solely as an escape hatch for portals nobody had foreseen — a footgun.

Self-registration makes the contract one-directional: a portal that lives inside a panel says so via context. There's no marker to forget, no role to set, no hardcoded selector list to extend in FloatingPanel.tsx. Bug pattern this kills: "clicking an option inside my popover closes the parent panel because I forgot to add data-floating-allow."

The two third-party selectors stay because Headless UI and react-tooltip mount their own portals — we can't make them self-register without forking. If you wrap a Headless UI primitive in your own portal, the inner Headless UI selector still catches the click.

Do NOT add new bespoke selectors to FloatingPanel.tsx — make your portal self-register instead.


Authoring checklist for a new popover-mode property

  • Flat body component exists (the actual editor, no FloatingPanel).
  • XPanel.tsx wraps body in FloatingPanel with sane defaultWidth/Height, storageKey, zIndex={1100}, persistSize={false}, and scrollable. The scrollable prop opts the panel into FloatingPanel's built-in AutoHideScrollbar + padded body wrapper (text-base-content flex flex-col gap-2 p-3 text-xs). Do NOT hand-roll your own <div className="...overflow-y-auto p-3..."> wrapper — that produces an ugly native browser scrollbar. If you need a different inner padding/typography, override via bodyClassName="..."; pass bodyClassName={null} to skip the inner wrapper entirely (rare).
  • XInputPopover.tsx:
    • reads value via useNode
    • computes summary + isEmpty
    • empty state matches the section convention (icon + for popover-only sections; Chip (mode="popover") with Add... for body rows)
    • has-value state uses Chip (mode="popover") from chrome/primitives/Chip.tsx — do not author a parallel input-wrapper h-8 ... block
    • listens to PopoverOpenRequestAtom (per-key version counter)
    • listens to SessionAddedAtom for legacy auto-open
    • implements clearXxx() that resets the props it owns
    • lazy-loads XPanel via React.lazy + <Suspense fallback={null}>
  • Registered in customInputs.tsx with chip(...) fallback.
  • Added to POPOVER_MODE set in popoverModeRegistry.ts.