Skip to content

Panel Contributions ​

Panel Contributions let any panel publish toolbar items and sidebar sections that are shown only while that panel is the active one (state.activePanelId). Two instances of the same panel type — two maps, two documents — stay fully independent: nothing here is shared or keyed globally, so switching between them always reflects each instance's own state.

Distinct from Panel Overlay

Panel Overlay → renders toolbars and floating windows inside a single panel's own DOM boundary — invisible to the rest of the app. Panel Contributions is the opposite direction: a panel reaches outward, publishing content that the workspace-level <RddToolbar>/<RddSidebar> renders on its behalf, only while it's active.

When to use it ​

  • A map panel that shows different tool buttons (Pan / Draw / Measure) depending on which controller is currently installed — with two open maps keeping independent selections.
  • A document/editor panel contributing a live "Table of Contents" sidebar section, extracted from its own content.
  • Any panel-specific action (formatting buttons, view toggles, search) that belongs in the app's shared chrome only while that panel has focus, instead of cluttering it permanently.

usePanelContribution(contribution) ​

Call inside a panel component, on every render, to publish what it wants shown. Republishes automatically when contribution changes; cleared automatically on unmount. Throws if used outside a DockableDesktopProvider tree.

tsx
import { usePanelContribution } from 'react-dockable-desktop';

function MapPanel() {
  const [controller, setController] = useState<'pan' | 'draw' | 'measure'>('pan');

  usePanelContribution({
    toolbarItems: (['pan', 'draw', 'measure'] as const).map(id => ({
      type: 'toggle',
      id,
      label: id,
      icon: icons[id],
      active: controller === id,          // self-reported — not ToolbarContext's shared state
      onToggle: () => setController(id),
    })),
    sidebarSections: [
      { id: 'layers', label: 'Layers', content: <LayerList /> },
    ],
  });

  // ...
}

Why active is on the item, not in ToolbarContext: ToolbarToggleItem/ToolbarRadioItem's uncontrolled state lives in the shared ToolbarContext, keyed by a plain string id — two panel instances contributing the same id would collide. Passing active explicitly (a controlled toggle, see Toolbar →) makes each instance's selection its own.

Memoize the object you pass (useMemo/useCallback for its arrays and callbacks) to avoid republishing on every unrelated re-render.

PanelContribution ​

FieldTypeDescription
toolbarItems?ToolbarItem[]Same type <RddToolbar items={...}> already accepts — no adapter needed.
sidebarSections?PanelSidebarSection[]{ id, label, icon?, content } — see useMergedSidebarTabs below for how these become SidebarTabs.

Both fields are independent — contribute only toolbar items, only sidebar sections, both, or neither.


useActiveContribution() ​

Reads state.activePanelId and returns whatever that panel last published via usePanelContribution(), or null if no panel is active or it contributed nothing. This is the raw, manual-control primitive — for the common case, prefer the two hooks below.

tsx
const active = useActiveContribution();
// active?.toolbarItems, active?.sidebarSections

useMergedToolbarItems(staticItems) / useMergedSidebarTabs(staticTabs) ​

Convenience wrappers around useActiveContribution() for the app shell — call once, get back a ready-to-render array, no manual splicing:

tsx
import { useMergedToolbarItems, useMergedSidebarTabs, RddToolbar, RddSidebar } from 'react-dockable-desktop';

function AppShell() {
  const toolbarItems = useMergedToolbarItems(myStaticToolbarItems);
  const sidebarTabs = useMergedSidebarTabs(myStaticSidebarTabs);

  return (
    <>
      <RddToolbar items={toolbarItems} />
      <RddSidebar tabs={sidebarTabs}>{/* ... */}</RddSidebar>
    </>
  );
}

useMergedToolbarItems appends the active panel's contributed items behind a separator; returns staticItems unchanged when there's nothing to add. useMergedSidebarTabs appends contributed sections as dynamic tabs — present only while their panel is active, never stealing focus from whichever tab the user already has open — converting each section via sectionToTab under the hood. SidebarTab.icon is optional but recommended unless the tab is hidden, so pass a second fallbackIcon argument if your sections may omit one.

Both are optional — useActiveContribution() stays fully usable for manual control (a different merge position, no separator, etc.).


sectionToTab(section, fallbackIcon?) ​

The pure converter useMergedSidebarTabs uses internally, exported for manual use:

ts
function sectionToTab(section: PanelSidebarSection, fallbackIcon?: React.ReactNode): SidebarTab

PanelSidebarSection.content is already a rendered ReactNode (not a callback), so the conversion is a straightforward renderContent: () => section.content plus an icon fallback. SidebarTab's eagerMount/preserveState have no contribution-side equivalent and are left unset — a contribution only exists while its owning panel is mounted and active.


Worked example: two independent maps ​

tsx
function MapPanel({ panelId }: { panelId: string }) {
  const [controller, setController] = useState<'pan' | 'draw' | 'measure'>('pan');

  usePanelContribution({
    toolbarItems: (['pan', 'draw', 'measure'] as const).map(id => ({
      type: 'toggle', id, label: id, icon: icons[id],
      active: controller === id, onToggle: () => setController(id),
    })),
  });

  return <MapView onControllerChange={setController} />;
}

Open two MapPanel instances. Select "Draw" on the first, switch to the second (still "Pan" by default), select "Measure" there, then switch back to the first — it's still on "Draw," exactly as left. Nothing extra was needed for this: docked/floating panels stay mounted while inactive, so each instance's own controller state was never at risk — the contribution mechanism just reflects whichever instance is currently active.

Panels must be focused, not just opened

openPanel() sets state.activePanelId by default, so a freshly-opened panel's contributions show up immediately. If you ever open with { focus: false } (to preload something in the background), call focusPanel(id) when you actually want its contributions to appear.


TypeScript exports ​

All exported from 'react-dockable-desktop':

ExportKindDescription
usePanelContributionHookPublish this panel's toolbar items/sidebar sections while active
useActiveContributionHookRead the active panel's published contribution manually
useMergedToolbarItemsHookstaticItems + active contribution's toolbar items, ready for <RddToolbar items={...}>
useMergedSidebarTabsHookstaticTabs + active contribution's sections as dynamic tabs, ready for <RddSidebar tabs={...}>
sectionToTabFunctionPure PanelSidebarSection → SidebarTab converter
PanelContributionInterface{ toolbarItems?, sidebarSections? }
PanelSidebarSectionInterface{ id, label, icon?, content }

See also ​

Released under the MIT License.