diff --git a/Source/Toolbar/Toolbar.css b/Source/Toolbar/Toolbar.css index fdd82d6..4689d8f 100644 --- a/Source/Toolbar/Toolbar.css +++ b/Source/Toolbar/Toolbar.css @@ -187,6 +187,22 @@ pointer-events: auto; } +/* + * Reveal complete. The clip-path did its job — the wipe cannot run without it, + * because `clip-path: none` is a discrete value that snaps instead of + * interpolating. But clip-path also clips every descendant, so leaving a zero + * inset in place slices off the hover tooltips of the outermost buttons, which + * paint outside the panel by design. + * + * ToolbarFanOutItem therefore adds this class once the reveal transition ends, + * and takes it off again (restoring an interpolable inset) before the close + * transition starts — so both wipes keep their exact timing and a settled panel + * clips nothing. + */ +.toolbar-fanout-panel--settled { + clip-path: none; +} + /* ── Toolbar folder ──────────────────────────────────────────────────────── */ .toolbar-folder-item { @@ -288,18 +304,25 @@ /* ── Toolbar slot transition (ToolbarGroup slot content) ─────────────────── */ /* - * Size-morphing container for slot content inside a ToolbarGroup. + * Size-morphing container for slot content inside a ToolbarGroup or ToolbarLayout. * Mirrors the .toolbar-section transition parameters for visual consistency — * the container resizes smoothly while content cross-fades. + * Overflow stays visible in the settled state so absolutely-positioned children + * (fan-out and folder panels, and the hover tooltips inside them) can escape the + * section's bounds; it is only clipped while outgoing content is fading out, so + * that content does not bleed past the container mid-transition. */ .toolbar-slot-section { position: relative; - overflow: hidden; transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), height 0.35s cubic-bezier(0.4, 0, 0.2, 1); } +.toolbar-slot-section--transitioning { + overflow: hidden; +} + @keyframes toolbar-slot-fade-in { from { opacity: 0; } to { opacity: 1; } diff --git a/Source/Toolbar/ToolbarFanOutItem.tsx b/Source/Toolbar/ToolbarFanOutItem.tsx index 2fa6787..a381f78 100644 --- a/Source/Toolbar/ToolbarFanOutItem.tsx +++ b/Source/Toolbar/ToolbarFanOutItem.tsx @@ -1,7 +1,7 @@ // 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 React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { IconDisplay } from '../Common/Icon'; import type { Icon } from '../Common/Icon'; import { Tooltip } from '../Common/Tooltip'; @@ -43,10 +43,40 @@ export const ToolbarFanOutItem = ({ children, }: ToolbarFanOutItemProps) => { const [isExpanded, setIsExpanded] = useState(false); + const [isSettled, setIsSettled] = useState(false); const containerRef = useRef(null); + const panelRef = useRef(null); + + // A settled panel has `clip-path: none`, which cannot interpolate — closing + // straight from it would snap the panel shut instead of wiping it closed. + // Put an equivalent inset back and let the browser observe it (the forced + // reflow) before React removes the visible class, so the close transition + // has an interpolable starting value. + const collapse = useCallback(() => { + const panel = panelRef.current; + if (panel && panel.classList.contains('toolbar-fanout-panel--settled')) { + panel.style.setProperty('clip-path', 'inset(0 0 0 0 round 1rem)'); + panel.style.setProperty('transition', 'none'); + void panel.offsetWidth; + panel.style.removeProperty('transition'); + panel.style.removeProperty('clip-path'); + } + setIsSettled(false); + setIsExpanded(false); + }, []); const handleToggle = () => { - setIsExpanded(!isExpanded); + if (isExpanded) { + collapse(); + } else { + setIsExpanded(true); + } + }; + + const handleTransitionEnd = (event: React.TransitionEvent) => { + if (event.target === panelRef.current && event.propertyName === 'clip-path' && isExpanded) { + setIsSettled(true); + } }; // Close the fan-out when clicking outside @@ -55,7 +85,7 @@ export const ToolbarFanOutItem = ({ const handleClickOutside = (event: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setIsExpanded(false); + collapse(); } }; @@ -63,10 +93,11 @@ export const ToolbarFanOutItem = ({ return () => { document.removeEventListener('mousedown', handleClickOutside); }; - }, [isExpanded]); + }, [isExpanded, collapse]); const activeClass = isExpanded ? 'toolbar-button--active' : ''; const panelVisibleClass = isExpanded ? 'toolbar-fanout-panel--visible' : ''; + const panelSettledClass = isExpanded && isSettled ? 'toolbar-fanout-panel--settled' : ''; const directionClass = `toolbar-fanout-panel--${fanOutDirection}`; return ( @@ -82,7 +113,11 @@ export const ToolbarFanOutItem = ({ -
+
{children}
diff --git a/Source/Toolbar/ToolbarGroup.tsx b/Source/Toolbar/ToolbarGroup.tsx index 4350db5..451d25c 100644 --- a/Source/Toolbar/ToolbarGroup.tsx +++ b/Source/Toolbar/ToolbarGroup.tsx @@ -83,9 +83,15 @@ const SlotTransition = ({ slotName, flexClass }: { slotName: string; flexClass: if (current.length === 0 && exiting.length === 0) return null; + // The section is only clipped while outgoing content is fading out, so it + // doesn't bleed outside the container. Once the transition is complete the + // section must be overflow:visible so fan-out and folder panels (which are + // position:absolute children) can escape the slot section's bounds. + const transitioningClass = exiting.length > 0 ? 'toolbar-slot-section--transitioning' : ''; + return (
{/* Incoming content — fades in via @keyframes animation on mount */} diff --git a/Source/Toolbar/ToolbarLayout.tsx b/Source/Toolbar/ToolbarLayout.tsx index a1a5390..f03e448 100644 --- a/Source/Toolbar/ToolbarLayout.tsx +++ b/Source/Toolbar/ToolbarLayout.tsx @@ -61,16 +61,16 @@ const LayoutTransition = ({ items, flexClass }: { items: ReactNode[]; flexClass: if (current.length === 0 && exiting.length === 0) return null; - // overflow:hidden is only needed while outgoing content is fading out so it + // The section is only clipped while outgoing content is fading out, so it // doesn't bleed outside the container. Once the transition is complete the // section must be overflow:visible so fan-out and folder panels (which are // position:absolute children) can escape the slot section's bounds. - const isTransitioning = exiting.length > 0; + const transitioningClass = exiting.length > 0 ? 'toolbar-slot-section--transitioning' : ''; return (