diff --git a/Documentation/Toolbar/toc.yml b/Documentation/Toolbar/toc.yml index 1ffc4fe..4d9790d 100644 --- a/Documentation/Toolbar/toc.yml +++ b/Documentation/Toolbar/toc.yml @@ -24,5 +24,5 @@ href: groups.md - name: Slots href: slots.md - - name: Toolbar Layout - href: toolbar-layout.md +- name: Toolbar Layout + href: toolbar-layout.md diff --git a/Source/Toolbar/Toolbar.stories.tsx b/Source/Toolbar/Toolbar.stories.tsx index 0409ec8..88fdfb6 100644 --- a/Source/Toolbar/Toolbar.stories.tsx +++ b/Source/Toolbar/Toolbar.stories.tsx @@ -719,286 +719,284 @@ 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: () => ( - - - +// ─── ToolbarLayout stories ──────────────────────────────────────────────────── + +/** + * Shows `ToolbarLayout` as a shared region in an editor shell. Different + * editor modules can mount and unmount independently while the shell remains + * unchanged. + */ +export const LayoutForEditorModules: Story = { + render: () => { + const LayoutForEditorModulesDemo = () => { + const [assetToolsEnabled, setAssetToolsEnabled] = useState(true); + const [reviewToolsEnabled, setReviewToolsEnabled] = useState(true); + + const assetTools = useMemo( + () => ( - - + + - - - - ), - }; - - /** - * 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 reviewTools = 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]} + return ( + +
+ + + + + + + + + + + + + + {assetToolsEnabled && ( + + {assetTools} + )} -
- Active mode -
- {(['draw', 'text', 'shape'] as const).map(mode => ( - - ))} + {reviewToolsEnabled && ( + + {reviewTools} + + )} + +
+ {[ + { label: 'Asset tools', enabled: assetToolsEnabled, toggle: () => setAssetToolsEnabled(v => !v) }, + { label: 'Review tools', enabled: reviewToolsEnabled, toggle: () => setReviewToolsEnabled(v => !v) }, + ].map(({ label, enabled, toggle }) => ( +
+ {label} +
+ ))} +
+
+ + ); + }; + + return ; + }, +}; + +/** + * Demonstrates editor-specific toolbar layouts (Canvas, Text, Schema) with a + * smooth transition when switching editor type. + * + * The transition is driven by `ToolbarLayout` itself, so the story only swaps + * slot content and the toolbar handles fade/resize animation internally. + */ +export const LayoutWithSmoothEditorTransitions: Story = { + render: () => { + const LayoutWithSmoothEditorTransitionsDemo = () => { + const [activeEditor, setActiveEditor] = useState<'canvas' | 'text' | 'schema'>('canvas'); + + const canvasTools = useMemo( + () => ( + + + + + + ), + [] + ); + + const textTools = useMemo( + () => ( + + + + + + ), + [] + ); + + const schemaTools = useMemo( + () => ( + <> + + + + + + + + + + ), + [] + ); + + const editorTools = { + canvas: canvasTools, + text: textTools, + schema: schemaTools, + }; + + return ( + +
+ + + + + + + + + + + + + + {editorTools[activeEditor]} + + +
+ Active editor +
+ {(['canvas', 'text', 'schema'] as const).map(editor => ( + + ))}
- - ); +
+
+ ); + }; + + return ; + }, +}; + +/** + * Shows app-level layout regions where one `ToolbarLayout` stays stable for + * global actions while another layout swaps by editor type. + */ +export const LayoutWithGlobalAndEditorRegions: Story = { + render: () => { + const LayoutWithGlobalAndEditorRegionsDemo = () => { + const [activeEditor, setActiveEditor] = useState<'page' | 'workflow'>('page'); + + const globalActions = useMemo( + () => ( + + + + + ), + [] + ); + + const pageEditorTools = useMemo( + () => ( + + + + + ), + [] + ); + + const workflowEditorTools = useMemo( + () => ( + + + + + + ), + [] + ); + + const editorRegionTools = { + page: pageEditorTools, + workflow: workflowEditorTools, }; - return ; - }, - }; + return ( + +
+ + + + + + + + {globalActions} + + + + {editorRegionTools[activeEditor]} + + +
+ {(['page', 'workflow'] as const).map(editor => ( + + ))} +
+
+
+ ); + }; + + return ; + }, +}; diff --git a/Source/Toolbar/ToolbarLayout.tsx b/Source/Toolbar/ToolbarLayout.tsx index 25aaf75..0878f61 100644 --- a/Source/Toolbar/ToolbarLayout.tsx +++ b/Source/Toolbar/ToolbarLayout.tsx @@ -1,9 +1,95 @@ // 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 { Children, ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useToolbarSlot } from './ToolbarSlot'; +/** How long the fade-out animation runs (ms). React unmounts exiting content after this. */ +const LAYOUT_TRANSITION_MS = 220; + +/** + * Renders toolbar layout content with a cross-fade and size-morph transition. + * + * - The container resizes smoothly to fit incoming layout content. + * - Outgoing content fades out while incoming content fades in. + */ +const LayoutTransition = ({ items, flexClass }: { items: ReactNode[]; flexClass: string }) => { + const [current, setCurrent] = useState(items); + const [exiting, setExiting] = useState([]); + const [exitRevision, setExitRevision] = useState(0); + + const currentRef = useRef(current); + currentRef.current = current; + + const incomingRef = useRef(null); + const [size, setSize] = useState<{ width: number; height: number } | null>(null); + const timerRef = useRef | undefined>(undefined); + const hasMountedRef = useRef(false); + + const measureIncoming = useCallback(() => { + if (incomingRef.current) { + setSize({ + width: incomingRef.current.offsetWidth, + height: incomingRef.current.offsetHeight, + }); + } + }, []); + + useLayoutEffect(() => { + measureIncoming(); + }, []); // mount only + + useEffect(() => { + if (items === currentRef.current) return; + const old = currentRef.current; + + if (timerRef.current !== undefined) clearTimeout(timerRef.current); + setCurrent(items); + + if (old.length > 0) { + setExiting(old); + setExitRevision(revision => revision + 1); + timerRef.current = setTimeout(() => setExiting([]), LAYOUT_TRANSITION_MS); + } + }, [items]); + + useEffect(() => { + if (!hasMountedRef.current) { + hasMountedRef.current = true; + return; + } + measureIncoming(); + }, [current, measureIncoming]); + + useEffect(() => () => { + if (timerRef.current !== undefined) clearTimeout(timerRef.current); + }, []); + + if (current.length === 0 && exiting.length === 0) return null; + + return ( +
+
+ {current} +
+ {exiting.length > 0 && ( +
+ {exiting} +
+ )} +
+ ); +}; + /** Props for the {@link ToolbarLayout} component. */ export interface ToolbarLayoutProps { /** @@ -71,13 +157,14 @@ export interface ToolbarLayoutProps { export const ToolbarLayout = ({ name, children, orientation = 'vertical' }: ToolbarLayoutProps) => { const slotItems = useToolbarSlot(name); const flexClass = orientation === 'horizontal' ? 'flex-row' : 'flex-col'; + const fallbackItems = useMemo(() => Children.toArray(children), [children]); + const items = slotItems.length > 0 ? slotItems : fallbackItems; - const content = slotItems.length > 0 ? slotItems : children; - if (content == null) return null; + if (items.length === 0) return null; return (
- {content} +
); };