Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions Source/Toolbar/Toolbar.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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; }
Expand Down
45 changes: 40 additions & 5 deletions Source/Toolbar/ToolbarFanOutItem.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -43,10 +43,40 @@ export const ToolbarFanOutItem = ({
children,
}: ToolbarFanOutItemProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const [isSettled, setIsSettled] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
if (event.target === panelRef.current && event.propertyName === 'clip-path' && isExpanded) {
setIsSettled(true);
}
};

// Close the fan-out when clicking outside
Expand All @@ -55,18 +85,19 @@ export const ToolbarFanOutItem = ({

const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsExpanded(false);
collapse();
}
};

document.addEventListener('mousedown', handleClickOutside);
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 (
Expand All @@ -82,7 +113,11 @@ export const ToolbarFanOutItem = ({
<IconDisplay icon={icon} className='text-lg' />
</button>
</Tooltip>
<div className={`toolbar-fanout-panel ${directionClass} ${panelVisibleClass}`}>
<div
ref={panelRef}
className={`toolbar-fanout-panel ${directionClass} ${panelVisibleClass} ${panelSettledClass}`}
onTransitionEnd={handleTransitionEnd}
>
{children}
</div>
</div>
Expand Down
8 changes: 7 additions & 1 deletion Source/Toolbar/ToolbarGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
className='toolbar-slot-section'
className={`toolbar-slot-section ${transitioningClass}`}
style={size ? { width: size.width, height: size.height } : undefined}
>
{/* Incoming content — fades in via @keyframes animation on mount */}
Expand Down
8 changes: 4 additions & 4 deletions Source/Toolbar/ToolbarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
className='toolbar-slot-section'
style={size ? { width: size.width, height: size.height, overflow: isTransitioning ? 'hidden' : 'visible' } : { overflow: 'visible' }}
className={`toolbar-slot-section ${transitioningClass}`}
style={size ? { width: size.width, height: size.height } : undefined}
>
<div
ref={incomingRef}
Expand Down
Loading