Improve tabs hover transition

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-06-29 14:23:21 +01:00
parent a252381557
commit 99bab1da3b
4 changed files with 105 additions and 41 deletions
@@ -14,12 +14,33 @@
* limitations under the License.
*/
.canon-ToolbarRoot {
display: flex;
flex-direction: column;
gap: var(--canon-space-2);
}
.canon-Toolbar {
display: flex;
flex-direction: row;
align-items: center;
background-color: var(--canon-bg-surface-1);
padding-inline: var(--canon-space-3);
border-radius: var(--canon-radius-2);
border: 1px solid var(--canon-border);
color: var(--canon-fg-primary);
height: 52px;
}
.canon-ToolbarTabs {
display: flex;
flex-direction: row;
align-items: center;
background-color: var(--canon-bg-surface-1);
position: relative;
padding-inline: var(--canon-space-1);
border-radius: var(--canon-radius-2);
border: 1px solid var(--canon-border);
/* Initialize CSS variables */
--active-tab-left: 0px;
@@ -35,6 +56,8 @@
--hovered-tab-bottom: 0px;
--hovered-tab-width: 0px;
--hovered-tab-height: 0px;
--hovered-tab-opacity: 0;
--hovered-transition-duration: 0s;
}
.canon-ToolbarTabList {
@@ -52,17 +75,18 @@
cursor: pointer;
position: relative;
z-index: 2;
padding-inline: var(--canon-space-2);
}
/* Active tab indicator */
.canon-ToolbarActiveIndicator {
content: '';
position: absolute;
left: var(--active-tab-left);
top: var(--active-tab-top);
width: var(--active-tab-width);
height: var(--active-tab-height);
background-color: green;
left: calc(var(--active-tab-left) + var(--canon-space-2));
bottom: -1px;
width: calc(var(--active-tab-width) - var(--canon-space-4));
height: 1px;
background-color: var(--canon-fg-primary);
border-radius: 4px;
transition: all 0.25s ease-out;
opacity: 1;
@@ -73,10 +97,14 @@
content: '';
position: absolute;
left: var(--hovered-tab-left);
top: var(--hovered-tab-top);
top: calc(var(--hovered-tab-top) + 4px);
width: var(--hovered-tab-width);
height: var(--hovered-tab-height);
background-color: red;
height: calc(var(--hovered-tab-height) - 8px);
background-color: var(--canon-gray-2);
border-radius: 4px;
transition: all 0.2s ease-out;
opacity: var(--hovered-tab-opacity);
transition: left var(--hovered-transition-duration) ease-out,
top var(--hovered-transition-duration) ease-out,
width var(--hovered-transition-duration) ease-out,
height var(--hovered-transition-duration) ease-out, opacity 0.15s ease-out;
}
@@ -28,9 +28,12 @@ import {
useEffect,
useState,
useCallback,
MutableRefObject,
} from 'react';
import { ToolbarProps, ToolbarTab } from './types';
import type {
ToolbarProps,
ToolbarIndicatorsProps,
ToolbarTabProps,
} from './types';
export const Toolbar = (props: ToolbarProps) => {
const { tabs } = props;
@@ -38,6 +41,7 @@ export const Toolbar = (props: ToolbarProps) => {
const tabsRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
const prevHoveredKey = useRef<string | null>(null);
const setTabRef = (key: string, element: HTMLDivElement | null) => {
if (element) {
@@ -48,8 +52,8 @@ export const Toolbar = (props: ToolbarProps) => {
};
return (
<>
<div>Toolbar</div>
<div className={classNames.root}>
<div className={classNames.toolbar}>Toolbar</div>
{tabs && (
<Tabs className={classNames.tabs} ref={tabsRef}>
<TabList className={classNames.tabList}>
@@ -64,26 +68,20 @@ export const Toolbar = (props: ToolbarProps) => {
);
})}
</TabList>
<Indicator
<Indicators
tabRefs={tabRefs}
tabsRef={tabsRef}
hoveredKey={hoveredKey}
prevHoveredKey={prevHoveredKey}
/>
</Tabs>
)}
</>
</div>
);
};
const Tab = ({
tab,
setTabRef,
setHoveredKey,
}: {
tab: ToolbarTab;
setTabRef: (key: string, element: HTMLDivElement | null) => void;
setHoveredKey: (key: string | null) => void;
}) => {
const Tab = (props: ToolbarTabProps) => {
const { tab, setTabRef, setHoveredKey } = props;
const id = useId();
const { classNames } = useStyles('Toolbar');
@@ -100,15 +98,8 @@ const Tab = ({
);
};
const Indicator = ({
tabRefs,
tabsRef,
hoveredKey,
}: {
tabRefs: MutableRefObject<Map<string, HTMLDivElement>>;
tabsRef: MutableRefObject<HTMLDivElement | null>;
hoveredKey: string | null;
}) => {
const Indicators = (props: ToolbarIndicatorsProps) => {
const { tabRefs, tabsRef, hoveredKey, prevHoveredKey } = props;
const { classNames } = useStyles('Toolbar');
const state = useContext(TabListStateContext);
@@ -120,7 +111,7 @@ const Indicator = ({
// Set active tab variables
if (state?.selectedKey) {
const activeTab = tabRefs.current.get(state.selectedKey.toString());
console.log(activeTab);
if (activeTab) {
const activeRect = activeTab.getBoundingClientRect();
const relativeLeft = activeRect.left - tabsRect.left;
@@ -185,15 +176,43 @@ const Indicator = ({
'--hovered-tab-height',
`${hoveredRect.height}px`,
);
// Control transition timing based on whether this is a new hover session
const isNewHoverSession = prevHoveredKey.current === null;
if (isNewHoverSession) {
// Starting new hover session: no transitions for position
tabsRef.current.style.setProperty(
'--hovered-transition-duration',
'0s',
);
// Enable transitions on next frame for future tab switches
requestAnimationFrame(() => {
if (tabsRef.current) {
tabsRef.current.style.setProperty(
'--hovered-transition-duration',
'0.2s',
);
}
});
} else {
// Moving between tabs in same session: full transitions
tabsRef.current.style.setProperty(
'--hovered-transition-duration',
'0.2s',
);
}
// Update previous hover key for next time
prevHoveredKey.current = hoveredKey;
tabsRef.current.style.setProperty('--hovered-tab-opacity', '1');
}
} else {
// Clear hovered variables when no tab is hovered
tabsRef.current.style.setProperty('--hovered-tab-left', '0px');
tabsRef.current.style.setProperty('--hovered-tab-right', '0px');
tabsRef.current.style.setProperty('--hovered-tab-top', '0px');
tabsRef.current.style.setProperty('--hovered-tab-bottom', '0px');
tabsRef.current.style.setProperty('--hovered-tab-width', '0px');
tabsRef.current.style.setProperty('--hovered-tab-height', '0px');
// When not hovering, hide with opacity and reset for next hover session
tabsRef.current.style.setProperty('--hovered-tab-opacity', '0');
// Reset previous hover key so next hover is treated as new session
prevHoveredKey.current = null;
}
}, [state?.selectedKey, hoveredKey]);
@@ -14,6 +14,8 @@
* limitations under the License.
*/
import { MutableRefObject } from 'react';
export interface ToolbarTab {
label: string;
}
@@ -21,3 +23,16 @@ export interface ToolbarTab {
export interface ToolbarProps {
tabs: ToolbarTab[];
}
export interface ToolbarTabProps {
tab: ToolbarTab;
setTabRef: (key: string, element: HTMLDivElement | null) => void;
setHoveredKey: (key: string | null) => void;
}
export interface ToolbarIndicatorsProps {
tabRefs: MutableRefObject<Map<string, HTMLDivElement>>;
tabsRef: MutableRefObject<HTMLDivElement | null>;
hoveredKey: string | null;
prevHoveredKey: MutableRefObject<string | null>;
}
@@ -237,6 +237,8 @@ export const componentDefinitions = {
},
Toolbar: {
classNames: {
root: 'canon-ToolbarRoot',
toolbar: 'canon-Toolbar',
tabs: 'canon-ToolbarTabs',
tabList: 'canon-ToolbarTabList',
tab: 'canon-ToolbarTab',