Improve toolbar

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-06-29 20:43:38 +01:00
parent 5554b13fa4
commit b0a6c8e6e2
9 changed files with 260 additions and 13 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/canon': patch
---
Add new Toolbar component to Canon.
+131
View File
@@ -9644,6 +9644,7 @@
}
.canon-MenuPositioner {
z-index: 100;
outline: 0;
}
@@ -10264,6 +10265,136 @@
height: 1rem;
}
.canon-Toolbar {
z-index: 10;
padding-top: 8px;
position: sticky;
top: 0;
&:before {
content: "";
background-color: var(--canon-bg);
z-index: 0;
height: 16px;
position: absolute;
top: 0;
left: 0;
right: 0;
}
}
.canon-ToolbarContentWrapper {
z-index: 1;
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);
flex-direction: row;
justify-content: space-between;
align-items: center;
height: 52px;
display: flex;
position: relative;
}
.canon-ToolbarContent {
align-items: center;
gap: var(--canon-space-2);
flex-direction: row;
display: flex;
}
.canon-ToolbarName {
align-items: center;
gap: var(--canon-space-2);
font-size: var(--canon-font-size-3);
font-weight: var(--canon-font-weight-regular);
flex-direction: row;
display: flex;
}
.canon-ToolbarIcon {
width: 16px;
height: 16px;
color: var(--canon-fg-primary);
& svg {
width: 100%;
height: 100%;
}
}
.canon-ToolbarTabs {
background-color: var(--canon-bg-surface-1);
padding-inline: var(--canon-space-1);
border-radius: var(--canon-radius-2);
border: 1px solid var(--canon-border);
--active-tab-left: 0px;
--active-tab-right: 0px;
--active-tab-top: 0px;
--active-tab-bottom: 0px;
--active-tab-width: 0px;
--active-tab-height: 0px;
--hovered-tab-left: 0px;
--hovered-tab-right: 0px;
--hovered-tab-top: 0px;
--hovered-tab-bottom: 0px;
--hovered-tab-width: 0px;
--hovered-tab-height: 0px;
--hovered-tab-opacity: 0;
--hovered-transition-duration: 0s;
flex-direction: row;
align-items: center;
margin-top: 8px;
display: flex;
position: relative;
}
.canon-ToolbarTabList {
flex-direction: row;
display: flex;
}
.canon-ToolbarTab {
font-size: var(--canon-font-size-3);
color: var(--canon-fg-primary);
cursor: pointer;
z-index: 2;
height: 36px;
padding-inline: var(--canon-space-2);
justify-content: center;
align-items: center;
display: flex;
position: relative;
}
.canon-ToolbarActiveIndicator {
content: "";
left: calc(var(--active-tab-left) + var(--canon-space-2));
width: calc(var(--active-tab-width) - var(--canon-space-4));
background-color: var(--canon-fg-primary);
opacity: 1;
border-radius: 4px;
height: 1px;
transition: all .25s ease-out;
position: absolute;
bottom: -1px;
}
.canon-ToolbarHoveredIndicator {
content: "";
left: var(--hovered-tab-left);
top: calc(var(--hovered-tab-top) + 4px);
width: var(--hovered-tab-width);
height: calc(var(--hovered-tab-height) - 8px);
background-color: var(--canon-gray-2);
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 .15s ease-out;
border-radius: 4px;
position: absolute;
}
.canon-TooltipPopup {
box-sizing: border-box;
transform-origin: var(--transform-origin);
+16
View File
@@ -508,6 +508,22 @@ export const componentDefinitions: {
readonly disabled: readonly [true, false];
};
};
readonly Toolbar: {
readonly classNames: {
readonly toolbar: 'canon-Toolbar';
readonly toolbarContentWrapper: 'canon-ToolbarContentWrapper';
readonly toolbarOverlay: 'canon-ToolbarOverlay';
readonly toolbarContent: 'canon-ToolbarContent';
readonly toolbarOptions: 'canon-ToolbarOptions';
readonly icon: 'canon-ToolbarIcon';
readonly name: 'canon-ToolbarName';
readonly tabs: 'canon-ToolbarTabs';
readonly tabList: 'canon-ToolbarTabList';
readonly tab: 'canon-ToolbarTab';
readonly activeIndicator: 'canon-ToolbarActiveIndicator';
readonly hoveredIndicator: 'canon-ToolbarHoveredIndicator';
};
};
readonly Tooltip: {
readonly classNames: {
readonly trigger: 'canon-TooltipTrigger';
@@ -1,5 +1,6 @@
.canon-MenuPositioner {
outline: 0;
z-index: 100;
}
.canon-MenuPopup {
@@ -19,7 +19,12 @@ import { useStyles } from '../../hooks/useStyles';
import { useContext, useEffect, useCallback } from 'react';
import type { ToolbarIndicatorsProps } from './types';
export const Indicators = (props: ToolbarIndicatorsProps) => {
/**
* A component that renders the indicators for the toolbar.
*
* @public
*/
export const ToolbarTabsIndicators = (props: ToolbarIndicatorsProps) => {
const { tabRefs, tabsRef, hoveredKey, prevHoveredKey } = props;
const { classNames } = useStyles('Toolbar');
const state = useContext(TabListStateContext);
@@ -17,13 +17,19 @@
import { Tabs, TabList, Tab as AriaTab } from 'react-aria-components';
import { useStyles } from '../../hooks/useStyles';
import { useId, useRef, useState } from 'react';
import { Indicators } from './Indicators';
import { ToolbarTabsIndicators } from './Indicators';
import { RiMore2Line, RiShapesLine } from '@remixicon/react';
import type { ToolbarProps, ToolbarTabProps } from './types';
import { ButtonIcon } from '../ButtonIcon';
import { Menu } from '../Menu';
/**
* A component that renders a toolbar.
*
* @public
*/
export const Toolbar = (props: ToolbarProps) => {
const { tabs, icon, name } = props;
const { tabs, icon, name, options } = props;
const { classNames } = useStyles('Toolbar');
const tabsRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<Map<string, HTMLDivElement>>(new Map());
@@ -49,7 +55,34 @@ export const Toolbar = (props: ToolbarProps) => {
</div>
</div>
<div className={classNames.toolbarOptions}>
<ButtonIcon icon={<RiMore2Line />} variant="secondary" />
{options && (
<Menu.Root>
<Menu.Trigger
render={props => (
<ButtonIcon
{...props}
size="small"
icon={<RiMore2Line />}
variant="secondary"
/>
)}
/>
<Menu.Portal>
<Menu.Positioner sideOffset={4} align="end">
<Menu.Popup>
{options.map(option => (
<Menu.Item
key={option.value}
onClick={() => option.onClick?.()}
>
{option.label}
</Menu.Item>
))}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
)}
</div>
</div>
</div>
@@ -67,7 +100,7 @@ export const Toolbar = (props: ToolbarProps) => {
);
})}
</TabList>
<Indicators
<ToolbarTabsIndicators
tabRefs={tabRefs}
tabsRef={tabsRef}
hoveredKey={hoveredKey}
@@ -79,6 +112,11 @@ export const Toolbar = (props: ToolbarProps) => {
);
};
/**
* A component that renders a toolbar tab.
*
* @public
*/
const Tab = (props: ToolbarTabProps) => {
const { tab, setTabRef, setHoveredKey } = props;
const id = useId();
@@ -34,8 +34,19 @@ const tabs = [
},
];
const options = [
{
label: 'Settings',
value: 'settings',
},
{
label: 'Invite new members',
value: 'invite-new-members',
},
];
// Extract layout decorator as a reusable constant
const withLayoutDecorator = [
const layoutDecorator = [
(Story: StoryFn) => (
<>
<div
@@ -86,19 +97,25 @@ export const WithTabs: Story = {
},
};
export const WithLayout: Story = {
args: {},
decorators: withLayoutDecorator,
parameters: {
layout: 'fullscreen',
export const WithOptions: Story = {
args: {
options,
},
};
export const WithTabsInLayout: Story = {
export const WithOptionsAndTabs: Story = {
args: {
options,
tabs,
},
decorators: withLayoutDecorator,
};
export const WithLayout: Story = {
args: {
options,
tabs,
},
decorators: layoutDecorator,
parameters: {
layout: 'fullscreen',
},
@@ -15,3 +15,5 @@
*/
export * from './Toolbar';
export * from './Indicators';
export * from './types';
@@ -16,22 +16,54 @@
import { MutableRefObject } from 'react';
/**
* Represents a tab item in the toolbar navigation.
*
* @public
*/
export interface ToolbarTab {
label: string;
}
/**
* Represents an option item in the toolbar dropdown menu.
*
* @public
*/
export interface ToolbarOption {
label: string;
value: string;
onClick?: () => void;
}
/**
* Props for the main Toolbar component.
*
* @public
*/
export interface ToolbarProps {
icon?: React.ReactNode;
name?: string;
tabs?: ToolbarTab[];
options?: ToolbarOption[];
}
/**
* Props for individual toolbar tab components.
*
* @public
*/
export interface ToolbarTabProps {
tab: ToolbarTab;
setTabRef: (key: string, element: HTMLDivElement | null) => void;
setHoveredKey: (key: string | null) => void;
}
/**
* Props for the toolbar indicators component that handles tab highlighting and animation.
*
* @public
*/
export interface ToolbarIndicatorsProps {
tabRefs: MutableRefObject<Map<string, HTMLDivElement>>;
tabsRef: MutableRefObject<HTMLDivElement | null>;