diff --git a/Documentation/Toolbar/toc.yml b/Documentation/Toolbar/toc.yml index 818a94e..1ffc4fe 100644 --- a/Documentation/Toolbar/toc.yml +++ b/Documentation/Toolbar/toc.yml @@ -24,3 +24,5 @@ href: groups.md - name: Slots href: slots.md + - name: Toolbar Layout + href: toolbar-layout.md diff --git a/Documentation/Toolbar/toolbar-layout.md b/Documentation/Toolbar/toolbar-layout.md new file mode 100644 index 0000000..161f02f --- /dev/null +++ b/Documentation/Toolbar/toolbar-layout.md @@ -0,0 +1,121 @@ +# Toolbar Layout + +`ToolbarLayout` is a named, transparent region inside a `Toolbar` that enables decoupled, dynamically swappable toolbar content. Unlike `ToolbarGroup`, it carries no visual container of its own — the injected content brings its own `ToolbarGroup` pills, `ToolbarSection` context switchers, and `ToolbarSeparator` dividers. + +The key capability: any component in the React tree can inject a complete toolbar layout — multiple groups, sections, and separators — into a named region without prop drilling. The injection is coordinated by the `ToolbarSlotProvider`. + +## When to use `ToolbarLayout` vs `ToolbarGroup` + +| | `ToolbarGroup` | `ToolbarLayout` | +|---|---|---| +| Visual container | Yes — renders as a pill | No — transparent pass-through | +| Slot content | Appended to its own children | Replaces fallback children | +| Intended content | Individual buttons | Complete toolbar structures (groups, sections, separators) | +| Use case | Cluster related buttons | Entire swappable toolbar regions | + +## Quick Start + +Wrap the toolbar and the contributing components in a `ToolbarSlotProvider`. Give `ToolbarLayout` a `name` prop that external components match in their `ToolbarSlot`: + +```tsx +import { ToolbarSlotProvider, ToolbarSlot } from '@cratis/components'; + +export const AppShell = () => ( + + + + + {/* Fallback — shown when no slot content is registered */} + + + + + + + + +); + +// Anywhere in the tree — injects a complete multi-group layout: +const CanvasFeature = () => { + const content = useMemo(() => ( + <> + + + + + + + + + + ), []); + + return {content}; +}; +``` + +## Fallback Children + +The `children` prop provides default content rendered when no slot content has been registered. As soon as any `ToolbarSlot` with a matching `slotName` mounts, the slot content replaces the fallback completely. + +If the layout region has no meaningful default, omit `children` entirely — `ToolbarLayout` renders nothing when both its slot and its children are empty: + +```tsx +{/* No fallback — layout is invisible until a feature registers */} + +``` + +## Multiple Contributors + +Multiple components can each register a `ToolbarSlot` with the same `slotName`. The `order` prop on each slot controls the render order (lower values appear first): + +```tsx +// Feature A — appears first + + + + + + +// Feature B — appears second + + <> + + + + + + +``` + +## Context-Sensitive Layouts + +A common pattern is to render a different slot content based on the active application mode. Mount and unmount (or swap the children of) a single `ToolbarSlot` as the mode changes: + +```tsx +const modeContent = { + draw: , + text: , + shape: , +}; + +// The ToolbarLayout region updates automatically as activeMode changes: + + {modeContent[activeMode]} + +``` + +## Props + +| Prop | Type | Default | Description | +|---|---|---|---| +| `name` | `string` | required | Identifies the layout region. Match this in `ToolbarSlot` `slotName`. | +| `children` | `ReactNode` | — | Fallback content shown when no slot content is registered. | +| `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | Layout direction — should match the parent `Toolbar`. | + +## Related + +- [Slots](slots.md) — the slot system that powers `ToolbarLayout` +- [Groups](groups.md) — `ToolbarGroup` for visually contained button clusters +- [Context Switching](context-switching.md) — `ToolbarSection` for animated context changes within a slot diff --git a/Source/Toolbar/Toolbar.stories.tsx b/Source/Toolbar/Toolbar.stories.tsx index 1a077a2..0409ec8 100644 --- a/Source/Toolbar/Toolbar.stories.tsx +++ b/Source/Toolbar/Toolbar.stories.tsx @@ -12,6 +12,7 @@ import { ToolbarGroup } from './ToolbarGroup'; import { ToolbarSection } from './ToolbarSection'; import { ToolbarSeparator } from './ToolbarSeparator'; import { ToolbarSlot, ToolbarSlotProvider } from './ToolbarSlot'; +import { ToolbarLayout } from './ToolbarLayout'; const meta: Meta = { title: 'Components/Toolbar', @@ -717,3 +718,287 @@ export const WithMultipleSlotContributors: Story = { ); }, }; + + // ─── ToolbarLayout stories ──────────────────────────────────────────────────── + + /** + * Demonstrates {@link ToolbarLayout} rendering its fallback children when no slot + * content has been registered. The layout acts as a transparent container so the + * injected {@link ToolbarGroup} pills appear exactly as they would if placed + * directly inside the {@link Toolbar}. + */ + export const LayoutWithDefaultContent: Story = { + render: () => ( + + + + + + + + + + + ), + }; + + /** + * Shows a feature injecting a complete toolbar layout — multiple groups and a + * separator — into a named {@link ToolbarLayout} region from anywhere in the tree. + * Toggle the "Feature mounted" switch to see the layout region swap between the + * default content and the injected content. + */ + export const LayoutWithInjectedContent: Story = { + render: () => { + const LayoutWithInjectedContentDemo = () => { + const [featureMounted, setFeatureMounted] = useState(true); + + const featureContent = useMemo( + () => ( + <> + + + + + + + + + + ), + [] + ); + + return ( + +
+ + + + + + + + + + + + + + {featureMounted && ( + + {featureContent} + + )} + +
+ + Feature mounted + + +
+
+
+ ); + }; + + return ; + }, + }; + + /** + * Demonstrates two independent features each contributing to the same + * {@link ToolbarLayout} region. The `order` prop on each {@link ToolbarSlot} controls + * which contribution appears first — lower values appear before higher ones. + * Toggle each feature to see how the layout region adapts. + */ + export const LayoutWithMultipleContributors: Story = { + render: () => { + const LayoutWithMultipleContributorsDemo = () => { + const [featureAMounted, setFeatureAMounted] = useState(true); + const [featureBMounted, setFeatureBMounted] = useState(true); + + const featureAContent = useMemo( + () => ( + + + + + ), + [] + ); + + const featureBContent = useMemo( + () => ( + <> + + + + + + + ), + [] + ); + + return ( + +
+ + + + + + + + + + + {featureAMounted && ( + + {featureAContent} + + )} + + {featureBMounted && ( + + {featureBContent} + + )} + +
+ {[ + { label: 'Feature A', mounted: featureAMounted, toggle: () => setFeatureAMounted(m => !m) }, + { label: 'Feature B', mounted: featureBMounted, toggle: () => setFeatureBMounted(m => !m) }, + ].map(({ label, mounted, toggle }) => ( +
+ {label} + +
+ ))} +
+
+
+ ); + }; + + return ; + }, + }; + + /** + * Shows how a {@link ToolbarLayout} with context-sensitive slot content mirrors + * the application's active mode. Each "mode" has its own feature component that + * mounts and unmounts a {@link ToolbarSlot} — the layout region swaps its full + * content automatically and independently from the rest of the toolbar. + */ + export const LayoutWithContextSensitiveContent: Story = { + render: () => { + const LayoutWithContextSensitiveContentDemo = () => { + const [activeMode, setActiveMode] = useState<'draw' | 'text' | 'shape'>('draw'); + + const drawContent = useMemo( + () => ( + <> + + + + + + + + + + ), + [] + ); + + const textContent = useMemo( + () => ( + + + + + + ), + [] + ); + + const shapeContent = useMemo( + () => ( + <> + + + + + + + + + + ), + [] + ); + + const modeContent = { draw: drawContent, text: textContent, shape: shapeContent }; + + return ( + +
+ + + + + + + + {modeContent[activeMode]} + + +
+ Active mode +
+ {(['draw', 'text', 'shape'] as const).map(mode => ( + + ))} +
+
+
+
+ ); + }; + + return ; + }, + }; diff --git a/Source/Toolbar/ToolbarLayout.tsx b/Source/Toolbar/ToolbarLayout.tsx new file mode 100644 index 0000000..25aaf75 --- /dev/null +++ b/Source/Toolbar/ToolbarLayout.tsx @@ -0,0 +1,83 @@ +// 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 { useToolbarSlot } from './ToolbarSlot'; + +/** Props for the {@link ToolbarLayout} component. */ +export interface ToolbarLayoutProps { + /** + * The name identifying this layout region. + * External components use this as the `slotName` on a {@link ToolbarSlot} to inject content. + */ + name: string; + + /** + * Default content shown when no slot content has been registered for this layout. + * Can include {@link ToolbarGroup}, {@link ToolbarSection}, {@link ToolbarSeparator}, + * or any other toolbar elements. + */ + children?: ReactNode; + + /** Layout direction matching the parent {@link Toolbar} (default: `'vertical'`). */ + orientation?: 'vertical' | 'horizontal'; +} + +/** + * A named, transparent layout region inside a {@link Toolbar} that enables decoupled, + * dynamically swappable toolbar content. + * + * Unlike {@link ToolbarGroup}, `ToolbarLayout` has no visual container of its own. + * It acts as a transparent mount point: external components inject complete toolbar + * structures — multiple {@link ToolbarGroup}s, {@link ToolbarSection}s, + * {@link ToolbarSeparator}s — using a {@link ToolbarSlot} with a matching `slotName`. + * + * When any slot content is registered it replaces the fallback `children`. Multiple + * independent contributors can register into the same layout using different `order` + * values on their {@link ToolbarSlot}. + * + * Wrap the toolbar and contributing components in a {@link ToolbarSlotProvider}: + * + * @example + * ```tsx + * // Application shell — define the layout region with optional fallback content: + * + * + * + * + * + * + * + * + * + * + * + * + * + * // Feature component — inject complete groups: + * const CanvasFeature = () => ( + * + * + * + * + * + * + * + * + * + * ); + * ``` + */ +export const ToolbarLayout = ({ name, children, orientation = 'vertical' }: ToolbarLayoutProps) => { + const slotItems = useToolbarSlot(name); + const flexClass = orientation === 'horizontal' ? 'flex-row' : 'flex-col'; + + const content = slotItems.length > 0 ? slotItems : children; + if (content == null) return null; + + return ( +
+ {content} +
+ ); +}; diff --git a/Source/Toolbar/index.ts b/Source/Toolbar/index.ts index 99ef325..aaad64f 100644 --- a/Source/Toolbar/index.ts +++ b/Source/Toolbar/index.ts @@ -20,3 +20,5 @@ export type { ToolbarFolderProps } from './ToolbarFolder'; export type { ToolbarFolderMode } from './ToolbarFolderContext'; export { ToolbarSlot, ToolbarSlotProvider, useToolbarSlot } from './ToolbarSlot'; export type { ToolbarSlotProps, ToolbarSlotProviderProps } from './ToolbarSlot'; +export { ToolbarLayout } from './ToolbarLayout'; +export type { ToolbarLayoutProps } from './ToolbarLayout';