From 7d6ba4c4fc54b08fb89ffaa52298538b3c464b75 Mon Sep 17 00:00:00 2001 From: Einar Date: Mon, 10 Aug 2026 16:55:57 +0200 Subject: [PATCH 1/2] Stop a settled fan-out panel from clipping its tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reveal is a clip-path wipe, so the clip has to exist while it animates — clip-path: none is discrete and snaps instead of interpolating. But clip-path clips every descendant, so leaving a zero inset in place after the wipe has landed slices off the hover tooltips of the outermost buttons, which paint outside the panel by design. Widening the settled inset is not a way out: the wipe front IS the inset travelling 100% -> 0, so bleeding it outward extends the travel and the reveal completes early (measured at ~95ms of a 350ms transition with a 32rem bleed). Release the clip once the reveal has landed instead, and put an interpolable inset back before a close begins, so both wipes keep their exact timing. Co-Authored-By: Claude Opus 5 (1M context) --- Source/Toolbar/Toolbar.css | 27 +++++++++++++++-- Source/Toolbar/ToolbarFanOutItem.tsx | 45 ++++++++++++++++++++++++---- 2 files changed, 65 insertions(+), 7 deletions(-) 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}
From 9b7b674adf4d473d5ef73013f37695df1953f16f Mon Sep 17 00:00:00 2001 From: Einar Date: Mon, 10 Aug 2026 16:55:57 +0200 Subject: [PATCH 2/2] Keep a settled ToolbarGroup slot section unclipped .toolbar-slot-section was permanently overflow:hidden, which clipped the fan-out and folder panels of any ToolbarGroup that hosts a slot. ToolbarLayout already worked around this with an inline overflow override; move that to a --transitioning modifier class so both hosts share it and the section is only clipped while outgoing content is fading out. Co-Authored-By: Claude Opus 5 (1M context) --- Source/Toolbar/ToolbarGroup.tsx | 8 +++++++- Source/Toolbar/ToolbarLayout.tsx | 8 ++++---- 2 files changed, 11 insertions(+), 5 deletions(-) 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 (