diff --git a/packages/canon/src/components/Toolbar/Indicators.tsx b/packages/canon/src/components/Toolbar/Indicators.tsx new file mode 100644 index 0000000000..cc4c92ed5f --- /dev/null +++ b/packages/canon/src/components/Toolbar/Indicators.tsx @@ -0,0 +1,156 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TabListStateContext } from 'react-aria-components'; +import { useStyles } from '../../hooks/useStyles'; +import { useContext, useEffect, useCallback } from 'react'; +import type { ToolbarIndicatorsProps } from './types'; + +export const Indicators = (props: ToolbarIndicatorsProps) => { + const { tabRefs, tabsRef, hoveredKey, prevHoveredKey } = props; + const { classNames } = useStyles('Toolbar'); + const state = useContext(TabListStateContext); + + const updateCSSVariables = useCallback(() => { + if (!tabsRef.current) return; + + const tabsRect = tabsRef.current.getBoundingClientRect(); + + // Set active tab variables + if (state?.selectedKey) { + const activeTab = tabRefs.current.get(state.selectedKey.toString()); + + if (activeTab) { + const activeRect = activeTab.getBoundingClientRect(); + const relativeLeft = activeRect.left - tabsRect.left; + const relativeTop = activeRect.top - tabsRect.top; + + tabsRef.current.style.setProperty( + '--active-tab-left', + `${relativeLeft}px`, + ); + tabsRef.current.style.setProperty( + '--active-tab-right', + `${relativeLeft + activeRect.width}px`, + ); + tabsRef.current.style.setProperty( + '--active-tab-top', + `${relativeTop}px`, + ); + tabsRef.current.style.setProperty( + '--active-tab-bottom', + `${relativeTop + activeRect.height}px`, + ); + tabsRef.current.style.setProperty( + '--active-tab-width', + `${activeRect.width}px`, + ); + tabsRef.current.style.setProperty( + '--active-tab-height', + `${activeRect.height}px`, + ); + } + } + + // Set hovered tab variables + if (hoveredKey) { + const hoveredTab = tabRefs.current.get(hoveredKey); + if (hoveredTab) { + const hoveredRect = hoveredTab.getBoundingClientRect(); + const relativeLeft = hoveredRect.left - tabsRect.left; + const relativeTop = hoveredRect.top - tabsRect.top; + + tabsRef.current.style.setProperty( + '--hovered-tab-left', + `${relativeLeft}px`, + ); + tabsRef.current.style.setProperty( + '--hovered-tab-right', + `${relativeLeft + hoveredRect.width}px`, + ); + tabsRef.current.style.setProperty( + '--hovered-tab-top', + `${relativeTop}px`, + ); + tabsRef.current.style.setProperty( + '--hovered-tab-bottom', + `${relativeTop + hoveredRect.height}px`, + ); + tabsRef.current.style.setProperty( + '--hovered-tab-width', + `${hoveredRect.width}px`, + ); + tabsRef.current.style.setProperty( + '--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 { + // 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]); + + useEffect(() => { + updateCSSVariables(); + }, [updateCSSVariables]); + + useEffect(() => { + const handleResize = () => updateCSSVariables(); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, [updateCSSVariables]); + + return ( + <> +
+ + > + ); +}; diff --git a/packages/canon/src/components/Toolbar/Toolbar.styles.css b/packages/canon/src/components/Toolbar/Toolbar.styles.css index edcb3b4b8d..780470c43c 100644 --- a/packages/canon/src/components/Toolbar/Toolbar.styles.css +++ b/packages/canon/src/components/Toolbar/Toolbar.styles.css @@ -14,16 +14,31 @@ * limitations under the License. */ -.canon-ToolbarRoot { - display: flex; - flex-direction: column; - gap: var(--canon-space-2); +.canon-Toolbar { + position: sticky; + top: 0; + z-index: 10; + padding-top: 8px; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0px; + right: 0px; + height: 16px; + background-color: var(--canon-bg); + z-index: 0; + } } -.canon-Toolbar { +.canon-ToolbarContentWrapper { + position: relative; + z-index: 1; display: flex; flex-direction: row; align-items: center; + justify-content: space-between; background-color: var(--canon-bg-surface-1); padding-inline: var(--canon-space-3); border-radius: var(--canon-radius-2); @@ -32,6 +47,33 @@ height: 52px; } +.canon-ToolbarContent { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--canon-space-2); +} + +.canon-ToolbarName { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--canon-space-2); + font-size: var(--canon-font-size-3); + font-weight: var(--canon-font-weight-regular); +} + +.canon-ToolbarIcon { + width: 16px; + height: 16px; + color: var(--canon-fg-primary); + + & svg { + width: 100%; + height: 100%; + } +} + .canon-ToolbarTabs { display: flex; flex-direction: row; @@ -41,6 +83,7 @@ padding-inline: var(--canon-space-1); border-radius: var(--canon-radius-2); border: 1px solid var(--canon-border); + margin-top: 8px; /* Initialize CSS variables */ --active-tab-left: 0px; diff --git a/packages/canon/src/components/Toolbar/Toolbar.tsx b/packages/canon/src/components/Toolbar/Toolbar.tsx index 65ee408399..9ef4d0edbd 100644 --- a/packages/canon/src/components/Toolbar/Toolbar.tsx +++ b/packages/canon/src/components/Toolbar/Toolbar.tsx @@ -14,29 +14,16 @@ * limitations under the License. */ -import { - Tabs, - TabList, - Tab as AriaTab, - TabListStateContext, -} from 'react-aria-components'; +import { Tabs, TabList, Tab as AriaTab } from 'react-aria-components'; import { useStyles } from '../../hooks/useStyles'; -import { - useId, - useContext, - useRef, - useEffect, - useState, - useCallback, -} from 'react'; -import type { - ToolbarProps, - ToolbarIndicatorsProps, - ToolbarTabProps, -} from './types'; +import { useId, useRef, useState } from 'react'; +import { Indicators } from './Indicators'; +import { RiMore2Line, RiShapesLine } from '@remixicon/react'; +import type { ToolbarProps, ToolbarTabProps } from './types'; +import { ButtonIcon } from '../ButtonIcon'; export const Toolbar = (props: ToolbarProps) => { - const { tabs } = props; + const { tabs, icon, name } = props; const { classNames } = useStyles('Toolbar'); const tabsRef = useRef