diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 598a325..06eb9b1 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -44,6 +44,30 @@
- ESLint errors about unused variables or improper types are blocking issues
- Fixing them takes seconds now, but accumulates into hours if deferred
+## Adding a New Component Module
+
+When adding a new top-level component folder (e.g. `Source/MyComponent/`), ALL of the following steps are mandatory:
+
+1. **Create `Source/MyComponent/index.ts`** — export all public types and components.
+2. **Add a namespace import and re-export in `Source/index.ts`**:
+ ```typescript
+ import * as MyComponent from './MyComponent';
+ // ... add to the export block
+ export { ..., MyComponent };
+ ```
+3. **Add a subpath export entry in `Source/package.json`** under `"exports"`:
+ ```json
+ "./MyComponent": {
+ "types": "./dist/esm/MyComponent/index.d.ts",
+ "require": "./dist/cjs/MyComponent/index.js",
+ "import": "./dist/esm/MyComponent/index.js"
+ }
+ ```
+4. **Create `Documentation/MyComponent/index.md`** and `Documentation/MyComponent/toc.yml`.
+5. **Register in `Documentation/toc.yml`** under the appropriate category.
+
+> The `package.json` entry is required for consumers to import directly via `@cratis/components/MyComponent`.
+
## Formatting
- Honor the existing code style and conventions in the project.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..55184fd
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,14 @@
+version: 2
+updates:
+- package-ecosystem: nuget
+ directory: "/"
+ schedule:
+ interval: daily
+ open-pull-requests-limit: 25
+ labels: []
+- package-ecosystem: npm
+ directory: "/"
+ schedule:
+ interval: daily
+ open-pull-requests-limit: 25
+ labels: []
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..a048e9f
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,33 @@
+## Summary
+
+Optional summary of the PR here. The GitHub release description is created from this comment so keep it nice and descriptive.
+Remember to remove sections that you don't need or use.
+
+If it does not make sense to have a summary, you can take that out as well.
+You would typically keep the summary only if there is a general theme change that can be summarized. If you find
+yourself saying the same thing as any of the bullet points in a slightly different way; then this section is not
+needed.
+
+### Added
+
+- Describe the added features
+
+### Changed
+
+- Describe the outwards facing code change
+
+### Fixed
+
+- Describe the fix and the bug
+
+### Removed
+
+- Describe what was removed and why
+
+### Security
+
+- Describe the security issue and the fix
+
+### Deprecated
+
+- Describe the part of the code being deprecated and why
diff --git a/Documentation/Toolbar/index.md b/Documentation/Toolbar/index.md
new file mode 100644
index 0000000..e5b6005
--- /dev/null
+++ b/Documentation/Toolbar/index.md
@@ -0,0 +1,149 @@
+# Toolbar
+
+The `Toolbar` component provides a canvas-style icon toolbar with support for orientations, active states, animated context switching, and fan-out sub-panels.
+
+## Components
+
+| Component | Description |
+|---|---|
+| `Toolbar` | Container that groups toolbar buttons into a pill-shaped bar |
+| `ToolbarButton` | Icon button with a hover tooltip |
+| `ToolbarSection` | Section within a toolbar that animates between named contexts |
+| `ToolbarContext` | Named context (set of buttons) inside a `ToolbarSection` |
+| `ToolbarFanOutItem` | Button that slides out a horizontal sub-panel on click |
+
+## Basic Usage
+
+Place `ToolbarButton` elements inside a `Toolbar`:
+
+```tsx
+import { Toolbar, ToolbarButton } from '@cratis/components';
+
+function MyToolbar() {
+ return (
+
+
+
+
+
+ );
+}
+```
+
+## Orientation
+
+The toolbar defaults to `vertical`. Pass `orientation='horizontal'` for a horizontal layout:
+
+```tsx
+
+
+
+
+```
+
+## Active State
+
+Use the `active` prop on `ToolbarButton` to highlight the selected tool:
+
+```tsx
+function DrawingToolbar() {
+ const [activeTool, setActiveTool] = useState('select');
+
+ return (
+
+ setActiveTool('select')}
+ />
+ setActiveTool('draw')}
+ />
+
+ );
+}
+```
+
+## Context Switching
+
+`ToolbarSection` and `ToolbarContext` enable smooth animated transitions between different sets of tools. When `activeContext` changes, the current buttons fade out, the section morphs to the new size, then the new buttons fade in.
+
+```tsx
+function ContextualToolbar() {
+ const [mode, setMode] = useState('drawing');
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+```
+
+Only the section transitions — buttons outside the section are unaffected.
+
+## Fan-Out Sub-Panel
+
+`ToolbarFanOutItem` replaces a regular button with one that slides out a horizontal panel of additional tools when clicked. The panel closes when clicking the button again or anywhere outside it.
+
+```tsx
+
+
+
+
+
+
+
+
+```
+
+By default the panel fans out to the right. Use `fanOutDirection='left'` when the toolbar is positioned on the right side of the screen:
+
+```tsx
+
+ ...
+
+```
+
+## Multiple Toolbar Groups
+
+Render multiple `Toolbar` instances to create separate groups, matching the style of canvas-based tools panels:
+
+```tsx
+
+
+
+
+
+
+
+
+
+
+```
+
+## Tooltip Position
+
+Both `ToolbarButton` and `ToolbarFanOutItem` default to showing tooltips on the `right`. Use `tooltipPosition` to override:
+
+```tsx
+
+```
+
+Valid values are `'top'`, `'right'`, `'bottom'`, and `'left'`.
diff --git a/Documentation/Toolbar/toc.yml b/Documentation/Toolbar/toc.yml
new file mode 100644
index 0000000..1ba183c
--- /dev/null
+++ b/Documentation/Toolbar/toc.yml
@@ -0,0 +1,2 @@
+- name: Overview
+ href: index.md
diff --git a/Documentation/toc.yml b/Documentation/toc.yml
index 44eaac0..58b61e5 100644
--- a/Documentation/toc.yml
+++ b/Documentation/toc.yml
@@ -24,6 +24,8 @@
href: Dialogs/toc.yml
- name: Dropdown
href: Dropdown/index.md
+ - name: Toolbar
+ href: Toolbar/toc.yml
- name: Specialized Components
items:
- name: PivotViewer
diff --git a/Source/Common/Tooltip.css b/Source/Common/Tooltip.css
new file mode 100644
index 0000000..bfc9388
--- /dev/null
+++ b/Source/Common/Tooltip.css
@@ -0,0 +1,10 @@
+/* Copyright (c) Cratis. All rights reserved. */
+/* Licensed under the MIT license. See LICENSE file in the project root for full license information. */
+
+/* ── Tooltip bubble ──────────────────────────────────────────────────────── */
+.tooltip-bubble {
+ background: var(--surface-100);
+ color: var(--text-color);
+ border: 1px solid var(--surface-border);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+}
diff --git a/Source/Common/Tooltip.tsx b/Source/Common/Tooltip.tsx
new file mode 100644
index 0000000..3bfa617
--- /dev/null
+++ b/Source/Common/Tooltip.tsx
@@ -0,0 +1,44 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import React from 'react';
+import './Tooltip.css';
+
+/** Position of the tooltip relative to its trigger element. */
+export type TooltipPosition = 'top' | 'right' | 'bottom' | 'left';
+
+/** Props for the {@link Tooltip} component. */
+export interface TooltipProps {
+ /** The text to display inside the tooltip. */
+ content: string;
+ /** Where the tooltip appears relative to the trigger (default: 'top'). */
+ position?: TooltipPosition;
+ /** The element that triggers the tooltip on hover. */
+ children: React.ReactNode;
+}
+
+const POSITION_CLASSES: Record = {
+ right: 'left-full ml-2 top-1/2 -translate-y-1/2',
+ left: 'right-full mr-2 top-1/2 -translate-y-1/2',
+ top: 'bottom-full mb-2 left-1/2 -translate-x-1/2',
+ bottom: 'top-full mt-2 left-1/2 -translate-x-1/2',
+};
+
+/**
+ * A CSS-only hover tooltip wrapper. Wraps any child element and displays
+ * a styled floating label on hover without relying on native browser tooltips.
+ */
+export const Tooltip: React.FC = ({ content, position = 'top', children }) => (
+
+ {children}
+
+ {content}
+
+
+);
diff --git a/Source/Common/index.ts b/Source/Common/index.ts
index 4e6c08a..2c2cb22 100644
--- a/Source/Common/index.ts
+++ b/Source/Common/index.ts
@@ -4,3 +4,4 @@
export * from './ErrorBoundary';
export * from './Page';
export * from './FormElement';
+export * from './Tooltip';
diff --git a/Source/Toolbar/Toolbar.css b/Source/Toolbar/Toolbar.css
new file mode 100644
index 0000000..499d154
--- /dev/null
+++ b/Source/Toolbar/Toolbar.css
@@ -0,0 +1,131 @@
+/* Copyright (c) Cratis. All rights reserved. */
+/* Licensed under the MIT license. See LICENSE file in the project root for full license information. */
+
+/* ── Toolbar container ───────────────────────────────────────────────────── */
+.toolbar {
+ background: var(--surface-card);
+ border: 1px solid var(--surface-border);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+}
+
+/* ── Toolbar button ──────────────────────────────────────────────────────── */
+.toolbar-button {
+ color: var(--text-color-secondary);
+ background: transparent;
+ border: none;
+ transition: background 0.15s, color 0.15s;
+}
+
+.toolbar-button:hover {
+ background: var(--surface-100);
+ color: var(--text-color);
+}
+
+.toolbar-button--active {
+ background: var(--primary-color);
+ color: var(--primary-color-text);
+}
+
+.toolbar-button--active:hover {
+ background: var(--primary-color);
+ color: var(--primary-color-text);
+}
+
+/* ── Toolbar project name label ──────────────────────────────────────────── */
+.toolbar-project-name {
+ padding: 0 8px;
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--text-color);
+ white-space: nowrap;
+}
+
+/* ── Toolbar section (context switching) ────────────────────────────────── */
+
+/*
+ * The section is a fixed-size container that transitions smoothly between
+ * the dimensions required by each context.
+ * overflow is intentionally left as visible so that absolutely-positioned
+ * children (e.g. fan-out panels) can extend beyond the section's bounds.
+ * Inactive contexts are hidden via opacity/pointer-events, not overflow clipping.
+ */
+.toolbar-section {
+ position: relative;
+ transition:
+ width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
+ height 0.35s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+/*
+ * Every context is absolutely stacked at the top-left of the section.
+ * Only the active one is visible; the others fade out and lose pointer events.
+ */
+.toolbar-context {
+ position: absolute;
+ top: 0;
+ left: 0;
+ transition: opacity 0.2s ease;
+}
+
+.toolbar-context--active {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.toolbar-context--inactive {
+ opacity: 0;
+ pointer-events: none;
+}
+
+/* ── Toolbar fan-out item ────────────────────────────────────────────────── */
+
+/*
+ * The wrapper provides the positioning context for the absolutely-placed panel.
+ */
+.toolbar-fanout-item {
+ position: relative;
+}
+
+/*
+ * The fan-out panel is a floating pill that mirrors the Toolbar container style.
+ * When collapsed, it's hidden. When expanded, it appears elevated at the same
+ * position as the trigger button, with the trigger button as the first item.
+ * Vertically centered with where the collapsed button would be.
+ */
+.toolbar-fanout-panel {
+ position: absolute;
+ top: 50%;
+ left: calc(-0.5rem - 1px);
+ transform: translateY(-50%);
+ display: inline-flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.5rem;
+ background: var(--surface-card);
+ border: 1px solid var(--surface-border);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+ border-radius: 1rem;
+ white-space: nowrap;
+ z-index: 10;
+ pointer-events: none;
+ opacity: 0;
+ clip-path: inset(0 100% 0 0 round 1rem);
+ transition:
+ clip-path 0.35s cubic-bezier(0.4, 0, 0.2, 1),
+ opacity 0.2s ease;
+}
+
+/* Panel fans out to the left — reveal animates right → left */
+.toolbar-fanout-panel--left {
+ left: auto;
+ right: calc(-0.5rem - 1px);
+ clip-path: inset(0 0 0 100% round 1rem);
+}
+
+/* Expanded state — fully visible, pointer events restored */
+.toolbar-fanout-panel--visible {
+ clip-path: inset(0 0 0 0 round 1rem);
+ opacity: 1;
+ pointer-events: auto;
+}
diff --git a/Source/Toolbar/Toolbar.stories.tsx b/Source/Toolbar/Toolbar.stories.tsx
new file mode 100644
index 0000000..23005b7
--- /dev/null
+++ b/Source/Toolbar/Toolbar.stories.tsx
@@ -0,0 +1,210 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import type { Meta, StoryObj } from '@storybook/react';
+import { useState } from 'react';
+import { Toolbar } from './Toolbar';
+import { ToolbarButton } from './ToolbarButton';
+import { ToolbarContext } from './ToolbarContext';
+import { ToolbarFanOutItem } from './ToolbarFanOutItem';
+import { ToolbarSection } from './ToolbarSection';
+
+const meta: Meta = {
+ title: 'Components/Toolbar',
+ component: Toolbar,
+ parameters: {
+ layout: 'centered',
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+/** A single toolbar group with several drawing-tool buttons. */
+export const Default: Story = {
+ render: () => (
+
+
+
+
+
+
+
+ ),
+};
+
+/** Two separate toolbar groups displayed side-by-side, mirroring the Miro-style layout. */
+export const MultipleGroups: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+};
+
+/** Demonstrates the active (selected) state of a toolbar button. */
+export const WithActiveButton: Story = {
+ render: () => {
+ const ActiveDemo = () => {
+ const [active, setActive] = useState('select');
+
+ return (
+
+ setActive('select')}
+ />
+ setActive('layers')}
+ />
+ setActive('rectangle')}
+ />
+ setActive('sticky')}
+ />
+
+ );
+ };
+
+ return ;
+ },
+};
+
+/**
+ * Demonstrates animated context switching within a single toolbar section.
+ *
+ * Click the buttons below the toolbar to switch between the "Drawing" and "Text"
+ * contexts. The section fades its current items out, morphs to the new size, then
+ * fades the new items in — while other sections (if present) remain unchanged.
+ */
+export const WithContexts: Story = {
+ render: () => {
+ const WithContextsDemo = () => {
+ const [currentContext, setCurrentContext] = useState('drawing');
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ };
+
+ return ;
+ },
+};
+
+/**
+ * Demonstrates a {@link ToolbarFanOutItem} inside a vertical toolbar.
+ *
+ * Click the "Shapes" button to expand the fan-out panel to the right.
+ * Click the button again or anywhere outside the panel to collapse it.
+ */
+export const WithFanOut: Story = {
+ render: () => {
+ const WithFanOutDemo = () => {
+ const [activeTool, setActiveTool] = useState('select');
+
+ return (
+
+ );
+ };
+
+ return ;
+ },
+};
diff --git a/Source/Toolbar/Toolbar.tsx b/Source/Toolbar/Toolbar.tsx
new file mode 100644
index 0000000..a273bcf
--- /dev/null
+++ b/Source/Toolbar/Toolbar.tsx
@@ -0,0 +1,28 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { ReactNode } from 'react';
+import './Toolbar.css';
+
+/** Props for the {@link Toolbar} component. */
+export interface ToolbarProps {
+ /** The {@link ToolbarButton} elements to render inside this toolbar group. */
+ children: ReactNode;
+ /** Layout direction of the toolbar (default: 'vertical'). */
+ orientation?: 'vertical' | 'horizontal';
+}
+
+/**
+ * A toolbar container that groups icon buttons with a rounded border,
+ * mimicking the style of tools panels found in canvas-based applications.
+ * Supports both vertical (default) and horizontal orientations.
+ */
+export const Toolbar = ({ children, orientation = 'vertical' }: ToolbarProps) => (
+
+ {children}
+
+);
diff --git a/Source/Toolbar/ToolbarButton.tsx b/Source/Toolbar/ToolbarButton.tsx
new file mode 100644
index 0000000..4d3ad71
--- /dev/null
+++ b/Source/Toolbar/ToolbarButton.tsx
@@ -0,0 +1,44 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { Tooltip } from '../Common/Tooltip';
+import type { TooltipPosition } from '../Common/Tooltip';
+
+/** Props for the {@link ToolbarButton} component. */
+export interface ToolbarButtonProps {
+ /** The PrimeIcons CSS class to use as the icon (e.g. 'pi pi-home'). */
+ icon: string;
+
+ /** Tooltip text shown when the user hovers over the button. */
+ tooltip: string;
+
+ /** Whether the button is currently in the active/selected state. */
+ active?: boolean;
+
+ /** Callback invoked when the button is clicked. */
+ onClick?: () => void;
+
+ /** Position of the tooltip relative to the button (default: 'right'). */
+ tooltipPosition?: TooltipPosition;
+}
+
+/**
+ * An icon button with a tooltip, intended to be placed inside a {@link Toolbar}.
+ * Uses the shared {@link Tooltip} component for consistent hover labels.
+ */
+export const ToolbarButton = ({ icon, tooltip, active = false, onClick, tooltipPosition = 'right' }: ToolbarButtonProps) => {
+ const activeClass = active ? 'toolbar-button--active' : '';
+
+ return (
+
+
+
+ );
+};
diff --git a/Source/Toolbar/ToolbarContext.tsx b/Source/Toolbar/ToolbarContext.tsx
new file mode 100644
index 0000000..8054172
--- /dev/null
+++ b/Source/Toolbar/ToolbarContext.tsx
@@ -0,0 +1,20 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import type { FC, ReactNode } from 'react';
+
+/** Props for the {@link ToolbarContext} component. */
+export interface ToolbarContextProps {
+ /** Unique name identifying this context within a {@link ToolbarSection}. */
+ name: string;
+ /** The toolbar items to render when this context is active. */
+ children: ReactNode;
+}
+
+/**
+ * Defines a named context (a set of toolbar items) within a {@link ToolbarSection}.
+ * The section renders only the active context at a time and animates between them.
+ *
+ * This is a data-only component; its rendering is fully managed by {@link ToolbarSection}.
+ */
+export const ToolbarContext: FC = () => null;
diff --git a/Source/Toolbar/ToolbarFanOutItem.tsx b/Source/Toolbar/ToolbarFanOutItem.tsx
new file mode 100644
index 0000000..5216008
--- /dev/null
+++ b/Source/Toolbar/ToolbarFanOutItem.tsx
@@ -0,0 +1,88 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { ReactNode, useEffect, useRef, useState } from 'react';
+import { Tooltip } from '../Common/Tooltip';
+import type { TooltipPosition } from '../Common/Tooltip';
+
+/** Props for the {@link ToolbarFanOutItem} component. */
+export interface ToolbarFanOutItemProps {
+ /** The PrimeIcons CSS class to use as the trigger icon (e.g. 'pi pi-home'). */
+ icon: string;
+
+ /** Tooltip text shown when hovering over the trigger button. */
+ tooltip: string;
+
+ /** Position of the tooltip relative to the trigger button (default: 'right'). */
+ tooltipPosition?: TooltipPosition;
+
+ /** Direction the panel fans out from the trigger button (default: 'right'). */
+ fanOutDirection?: 'right' | 'left';
+
+ /** The toolbar items to render inside the fan-out panel. */
+ children: ReactNode;
+}
+
+/**
+ * A toolbar button that fans out a horizontal panel of sub-tool buttons when clicked.
+ *
+ * Place this inside a vertical {@link Toolbar}. When the button is clicked, a pill-shaped
+ * panel slides out to the side (right by default) containing the provided children.
+ * The panel animates in/out using a clip-path reveal transition.
+ *
+ * - Clicking the button again closes the panel
+ * - Clicking anywhere outside the panel also closes it
+ */
+export const ToolbarFanOutItem = ({
+ icon,
+ tooltip,
+ tooltipPosition = 'right',
+ fanOutDirection = 'right',
+ children,
+}: ToolbarFanOutItemProps) => {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const containerRef = useRef(null);
+
+ const handleToggle = () => {
+ setIsExpanded(!isExpanded);
+ };
+
+ // Close the fan-out when clicking outside
+ useEffect(() => {
+ if (!isExpanded) return;
+
+ const handleClickOutside = (event: MouseEvent) => {
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
+ setIsExpanded(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [isExpanded]);
+
+ const activeClass = isExpanded ? 'toolbar-button--active' : '';
+ const panelVisibleClass = isExpanded ? 'toolbar-fanout-panel--visible' : '';
+ const directionClass = `toolbar-fanout-panel--${fanOutDirection}`;
+
+ return (
+
+
+
+
+
+ {children}
+
+
+ );
+};
diff --git a/Source/Toolbar/ToolbarSection.tsx b/Source/Toolbar/ToolbarSection.tsx
new file mode 100644
index 0000000..ac97e97
--- /dev/null
+++ b/Source/Toolbar/ToolbarSection.tsx
@@ -0,0 +1,92 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { Children, ReactElement, ReactNode, isValidElement, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
+
+import type { ToolbarContextProps } from './ToolbarContext';
+
+/** Props for the {@link ToolbarSection} component. */
+export interface ToolbarSectionProps {
+ /**
+ * The name of the currently active context.
+ * Change this to trigger the fade-out / morph / fade-in animation.
+ * Defaults to the first context if not specified.
+ */
+ activeContext?: string;
+
+ /** {@link ToolbarContext} elements that define each context's toolbar items. */
+ children: ReactNode;
+
+ /** Layout direction matching the parent {@link Toolbar} (default: 'vertical'). */
+ orientation?: 'vertical' | 'horizontal';
+}
+
+/**
+ * A section within a {@link Toolbar} that supports multiple named contexts.
+ *
+ * When {@link activeContext} changes:
+ * - The current items fade out.
+ * - The section smoothly morphs to the dimensions required by the new context.
+ * - The new items fade in.
+ *
+ * Contexts are defined by placing {@link ToolbarContext} children inside this section.
+ * Switching contexts only affects this section; other sections are unaffected.
+ */
+export const ToolbarSection = ({ activeContext, children, orientation = 'vertical' }: ToolbarSectionProps) => {
+ const contextRefs = useRef>({});
+ const [size, setSize] = useState<{ width: number; height: number } | null>(null);
+
+ const contexts = Children.toArray(children).filter(
+ (child): child is ReactElement =>
+ isValidElement(child) && typeof (child.props as ToolbarContextProps).name === 'string'
+ );
+
+ // Default to the first context if activeContext is not provided
+ const effectiveContext = activeContext ?? (contexts[0]?.props as ToolbarContextProps)?.name ?? '';
+
+ const flexClass = orientation === 'horizontal' ? 'flex-row' : 'flex-col';
+
+ /** Measure the given context's natural dimensions and update the section size. */
+ const measureAndSetSize = useCallback((contextName: string) => {
+ const ref = contextRefs.current[contextName];
+ if (ref) {
+ setSize({ width: ref.offsetWidth, height: ref.offsetHeight });
+ }
+ }, []);
+
+ // Set the initial size synchronously before the first browser paint so there is no layout flash.
+ // Empty dependency array is intentional: ongoing context changes are handled by the useEffect below.
+ useLayoutEffect(() => {
+ measureAndSetSize(effectiveContext);
+ }, []); // run only on mount
+
+ // After a context switch, let the browser paint the opacity transition first,
+ // then update the explicit size so the CSS width/height transition kicks in.
+ useEffect(() => {
+ measureAndSetSize(effectiveContext);
+ }, [effectiveContext, measureAndSetSize]);
+
+ return (
+