Improve toolbar content

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-06-29 15:12:05 +01:00
parent 99bab1da3b
commit 5554b13fa4
6 changed files with 310 additions and 178 deletions
@@ -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 (
<>
<div className={classNames.activeIndicator} />
<div className={classNames.hoveredIndicator} />
</>
);
};
@@ -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;
+22 -159
View File
@@ -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<HTMLDivElement>(null);
const tabRefs = useRef<Map<string, HTMLDivElement>>(new Map());
@@ -52,8 +39,20 @@ export const Toolbar = (props: ToolbarProps) => {
};
return (
<div className={classNames.root}>
<div className={classNames.toolbar}>Toolbar</div>
<>
<div className={classNames.toolbar}>
<div className={classNames.toolbarContentWrapper}>
<div className={classNames.toolbarContent}>
<div className={classNames.name}>
<div className={classNames.icon}>{icon || <RiShapesLine />}</div>
{name || 'Your plugin'}
</div>
</div>
<div className={classNames.toolbarOptions}>
<ButtonIcon icon={<RiMore2Line />} variant="secondary" />
</div>
</div>
</div>
{tabs && (
<Tabs className={classNames.tabs} ref={tabsRef}>
<TabList className={classNames.tabList}>
@@ -76,7 +75,7 @@ export const Toolbar = (props: ToolbarProps) => {
/>
</Tabs>
)}
</div>
</>
);
};
@@ -97,139 +96,3 @@ const Tab = (props: ToolbarTabProps) => {
</AriaTab>
);
};
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 (
<>
<div className={classNames.activeIndicator} />
<div className={classNames.hoveredIndicator} />
</>
);
};
@@ -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<typeof meta>;
const tabs = [
{
label: 'Home',
},
{
label: 'About',
},
];
// Extract layout decorator as a reusable constant
const withLayoutDecorator = [
(Story: StoryFn) => (
<>
<div
style={{
width: '250px',
position: 'fixed',
left: 8,
top: 8,
bottom: 8,
backgroundColor: 'var(--canon-bg-surface-1',
borderRadius: 'var(--canon-radius-2)',
border: '1px solid var(--canon-border)',
zIndex: 1,
}}
/>
<div
style={{
paddingInline: '266px',
minHeight: '200vh',
}}
>
<Story />
</div>
<div
style={{
width: '250px',
position: 'fixed',
right: 8,
top: 8,
bottom: 8,
backgroundColor: 'var(--canon-bg-surface-1',
borderRadius: 'var(--canon-radius-2)',
border: '1px solid var(--canon-border)',
zIndex: 1,
}}
/>
</>
),
];
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',
},
};
@@ -21,7 +21,9 @@ export interface ToolbarTab {
}
export interface ToolbarProps {
tabs: ToolbarTab[];
icon?: React.ReactNode;
name?: string;
tabs?: ToolbarTab[];
}
export interface ToolbarTabProps {
@@ -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',