From 5554b13fa43bcfa81e40707454c3ca09051c1072 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 29 Jun 2025 15:12:05 +0100 Subject: [PATCH] Improve toolbar content Signed-off-by: Charles de Dreuille --- .../src/components/Toolbar/Indicators.tsx | 156 +++++++++++++++ .../src/components/Toolbar/Toolbar.styles.css | 53 ++++- .../canon/src/components/Toolbar/Toolbar.tsx | 181 +++--------------- .../components/Toolbar/Tooltip.stories.tsx | 87 +++++++-- .../canon/src/components/Toolbar/types.ts | 4 +- .../canon/src/utils/componentDefinitions.ts | 7 +- 6 files changed, 310 insertions(+), 178 deletions(-) create mode 100644 packages/canon/src/components/Toolbar/Indicators.tsx 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(null); const tabRefs = useRef>(new Map()); @@ -52,8 +39,20 @@ export const Toolbar = (props: ToolbarProps) => { }; return ( -
-
Toolbar
+ <> +
+
+
+
+
{icon || }
+ {name || 'Your plugin'} +
+
+
+ } variant="secondary" /> +
+
+
{tabs && ( @@ -76,7 +75,7 @@ export const Toolbar = (props: ToolbarProps) => { /> )} -
+ ); }; @@ -97,139 +96,3 @@ const Tab = (props: ToolbarTabProps) => { ); }; - -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/Tooltip.stories.tsx b/packages/canon/src/components/Toolbar/Tooltip.stories.tsx index e1c6d67e64..67c02b3e9e 100644 --- a/packages/canon/src/components/Toolbar/Tooltip.stories.tsx +++ b/packages/canon/src/components/Toolbar/Tooltip.stories.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj, StoryFn } from '@storybook/react'; import { Toolbar } from './Toolbar'; const meta = { @@ -25,18 +25,81 @@ const meta = { export default meta; type Story = StoryObj; +const tabs = [ + { + label: 'Home', + }, + { + label: 'About', + }, +]; + +// Extract layout decorator as a reusable constant +const withLayoutDecorator = [ + (Story: StoryFn) => ( + <> +
+
+ +
+
+ + ), +]; + export const Default: Story = { + args: {}, +}; + +export const WithTabs: Story = { args: { - tabs: [ - { - label: 'Home', - }, - { - label: 'About', - }, - { - label: 'Contact Super long tab name', - }, - ], + tabs, + }, +}; + +export const WithLayout: Story = { + args: {}, + decorators: withLayoutDecorator, + parameters: { + layout: 'fullscreen', + }, +}; + +export const WithTabsInLayout: Story = { + args: { + tabs, + }, + decorators: withLayoutDecorator, + parameters: { + layout: 'fullscreen', }, }; diff --git a/packages/canon/src/components/Toolbar/types.ts b/packages/canon/src/components/Toolbar/types.ts index 572a1beee7..fad28468f3 100644 --- a/packages/canon/src/components/Toolbar/types.ts +++ b/packages/canon/src/components/Toolbar/types.ts @@ -21,7 +21,9 @@ export interface ToolbarTab { } export interface ToolbarProps { - tabs: ToolbarTab[]; + icon?: React.ReactNode; + name?: string; + tabs?: ToolbarTab[]; } export interface ToolbarTabProps { diff --git a/packages/canon/src/utils/componentDefinitions.ts b/packages/canon/src/utils/componentDefinitions.ts index ee3e9775d2..9aa18f09d0 100644 --- a/packages/canon/src/utils/componentDefinitions.ts +++ b/packages/canon/src/utils/componentDefinitions.ts @@ -237,8 +237,13 @@ export const componentDefinitions = { }, Toolbar: { classNames: { - root: 'canon-ToolbarRoot', toolbar: 'canon-Toolbar', + toolbarContentWrapper: 'canon-ToolbarContentWrapper', + toolbarOverlay: 'canon-ToolbarOverlay', + toolbarContent: 'canon-ToolbarContent', + toolbarOptions: 'canon-ToolbarOptions', + icon: 'canon-ToolbarIcon', + name: 'canon-ToolbarName', tabs: 'canon-ToolbarTabs', tabList: 'canon-ToolbarTabList', tab: 'canon-ToolbarTab',