Breadcrumb responsive improvements.

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-06-30 14:22:17 +01:00
parent f0e6f06cbf
commit 912907e28e
5 changed files with 180 additions and 12 deletions
@@ -61,6 +61,7 @@
gap: var(--canon-space-2);
font-size: var(--canon-font-size-3);
font-weight: var(--canon-font-weight-regular);
flex-shrink: 0;
}
.canon-ToolbarIcon {
@@ -74,6 +75,45 @@
}
}
.canon-ToolbarBreadcrumbs {
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-ToolbarBreadcrumb {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--canon-space-2);
}
.canon-ToolbarBreadcrumbLink {
color: var(--canon-fg-secondary);
text-decoration: none;
cursor: pointer;
&[data-active='true'] {
color: var(--canon-fg-primary);
}
}
.canon-ToolbarBreadcrumbSeparator {
width: 16px;
height: 16px;
color: var(--canon-fg-secondary);
}
.canon-ToolbarOptions {
position: absolute;
right: var(--canon-space-2);
top: 50%;
transform: translateY(-50%);
}
.canon-ToolbarTabs {
display: flex;
flex-direction: row;
@@ -84,7 +124,6 @@
border-radius: var(--canon-radius-2);
border: 1px solid var(--canon-border);
margin-top: 8px;
overflow: hidden;
/* Initialize CSS variables */
--active-tab-left: 0px;
@@ -14,14 +14,15 @@
* limitations under the License.
*/
import { Tabs, TabList, Tab as AriaTab } from 'react-aria-components';
import { Tabs, TabList, Tab as AriaTab, Link } from 'react-aria-components';
import { useStyles } from '../../hooks/useStyles';
import { useId, useRef, useState } from 'react';
import { useId, useRef, useState, useEffect } from 'react';
import { ToolbarTabsIndicators } from './Indicators';
import { RiMore2Line, RiShapesLine } from '@remixicon/react';
import { RiArrowRightSLine, RiMore2Line, RiShapesLine } from '@remixicon/react';
import type { ToolbarProps, ToolbarTabProps } from './types';
import { ButtonIcon } from '../ButtonIcon';
import { Menu } from '../Menu';
import { motion, useScroll, useTransform } from 'framer-motion';
/**
* A component that renders a toolbar.
@@ -29,12 +30,22 @@ import { Menu } from '../Menu';
* @public
*/
export const Toolbar = (props: ToolbarProps) => {
const { tabs, icon, name, options } = props;
const { tabs, icon, name, options, breadcrumbs } = props;
const { classNames } = useStyles('Toolbar');
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 { scrollY } = useScroll();
const breadcrumbOpacity = useTransform(scrollY, [80, 120], [0, 1]);
// Refs for collision detection
const toolbarWrapperRef = useRef<HTMLDivElement>(null);
const toolbarContentRef = useRef<HTMLDivElement>(null);
const toolbarOptionsRef = useRef<HTMLDivElement>(null);
// State for breadcrumb visibility
const [showBreadcrumbs, setShowBreadcrumbs] = useState(true);
const setTabRef = (key: string, element: HTMLDivElement | null) => {
if (element) {
@@ -44,17 +55,94 @@ export const Toolbar = (props: ToolbarProps) => {
}
};
// Set up resize observer
useEffect(() => {
const wrapper = toolbarWrapperRef.current;
if (!wrapper) return;
const resizeObserver = new ResizeObserver(() => {
const wrapper = toolbarWrapperRef.current;
const content = toolbarContentRef.current;
const options = toolbarOptionsRef.current;
if (!wrapper || !content) return;
// Get dimensions
const wrapperRect = wrapper.getBoundingClientRect();
const wrapperWidth = wrapperRect.width;
const wrapperPadding = 24; // 12px on each side (var(--canon-space-3))
const availableWidth = wrapperWidth - wrapperPadding;
// Calculate required width for content
const contentRect = content.getBoundingClientRect();
const contentWidth = contentRect?.width || 0;
// Calculate options width (if exists)
const optionsRect = options?.getBoundingClientRect();
const optionsWidth = optionsRect?.width || 0;
// Check if we need to hide breadcrumbs
const shouldShowBreadcrumbs =
contentWidth + optionsWidth + 32 <= availableWidth;
// Only update state if the value actually changed to prevent flickering
setShowBreadcrumbs(prev =>
prev !== shouldShowBreadcrumbs ? shouldShowBreadcrumbs : prev,
);
});
resizeObserver.observe(wrapper);
return () => {
resizeObserver.disconnect();
};
}, []);
return (
<>
<div className={classNames.toolbar}>
<div className={classNames.toolbarContentWrapper}>
<div className={classNames.toolbarContent}>
<div
className={classNames.toolbarContentWrapper}
ref={toolbarWrapperRef}
>
<div className={classNames.toolbarContent} ref={toolbarContentRef}>
<div className={classNames.name}>
<div className={classNames.icon}>{icon || <RiShapesLine />}</div>
{name || 'Your plugin'}
</div>
{breadcrumbs && (
<motion.div
className={classNames.breadcrumbs}
style={{
opacity: breadcrumbOpacity,
visibility: showBreadcrumbs ? 'visible' : 'hidden',
}}
>
<RiArrowRightSLine
size={16}
className={classNames.breadcrumbSeparator}
/>
{breadcrumbs.map((breadcrumb, index) => (
<div key={breadcrumb.label} className={classNames.breadcrumb}>
<Link
href={breadcrumb.href}
className={classNames.breadcrumbLink}
data-active={index === breadcrumbs.length - 1}
>
{breadcrumb.label}
</Link>
{index < breadcrumbs.length - 1 && (
<RiArrowRightSLine
size={16}
className={classNames.breadcrumbSeparator}
/>
)}
</div>
))}
</motion.div>
)}
</div>
<div className={classNames.toolbarOptions}>
<div className={classNames.toolbarOptions} ref={toolbarOptionsRef}>
{options && (
<Menu.Root>
<Menu.Trigger
@@ -88,7 +176,7 @@ export const Toolbar = (props: ToolbarProps) => {
</div>
{tabs && (
<Tabs className={classNames.tabs} ref={tabsRef}>
<TabList className={classNames.tabList}>
<TabList className={classNames.tabList} aria-label="Toolbar tabs">
{tabs.map((tab, index) => {
return (
<Tab
@@ -129,6 +217,7 @@ const Tab = (props: ToolbarTabProps) => {
ref={el => setTabRef(id, el as HTMLDivElement)}
onHoverStart={() => setHoveredKey(id)}
onHoverEnd={() => setHoveredKey(null)}
href={tab.href}
>
{tab.label}
</AriaTab>
@@ -16,6 +16,7 @@
import type { Meta, StoryObj, StoryFn } from '@storybook/react';
import { Toolbar } from './Toolbar';
import { ToolbarBreadcrumb, ToolbarOption, ToolbarTab } from './types';
const meta = {
title: 'Components/Toolbar',
@@ -25,7 +26,7 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
const tabs = [
const tabs: ToolbarTab[] = [
{
label: 'Overview',
},
@@ -43,7 +44,22 @@ const tabs = [
},
];
const options = [
const breadcrumbs: ToolbarBreadcrumb[] = [
{
label: 'Home',
href: '/',
},
{
label: 'Dashboard',
href: '/dashboard',
},
{
label: 'Settings',
href: '/settings',
},
];
const options: ToolbarOption[] = [
{
label: 'Settings',
value: 'settings',
@@ -112,10 +128,17 @@ export const WithOptions: Story = {
},
};
export const WithOptionsAndTabs: Story = {
export const WithBreadcrumbs: Story = {
args: {
breadcrumbs,
},
};
export const WithAllComponents: Story = {
args: {
options,
tabs,
breadcrumbs,
},
};
@@ -123,6 +146,7 @@ export const WithLayout: Story = {
args: {
options,
tabs,
breadcrumbs,
},
decorators: layoutDecorator,
parameters: {
@@ -23,6 +23,7 @@ import { MutableRefObject } from 'react';
*/
export interface ToolbarTab {
label: string;
href?: string;
}
/**
@@ -36,6 +37,16 @@ export interface ToolbarOption {
onClick?: () => void;
}
/**
* Represents a breadcrumb item in the toolbar.
*
* @public
*/
export interface ToolbarBreadcrumb {
label: string;
href: string;
}
/**
* Props for the main Toolbar component.
*
@@ -46,6 +57,7 @@ export interface ToolbarProps {
name?: string;
tabs?: ToolbarTab[];
options?: ToolbarOption[];
breadcrumbs?: ToolbarBreadcrumb[];
}
/**
@@ -244,6 +244,10 @@ export const componentDefinitions = {
toolbarOptions: 'canon-ToolbarOptions',
icon: 'canon-ToolbarIcon',
name: 'canon-ToolbarName',
breadcrumbs: 'canon-ToolbarBreadcrumbs',
breadcrumb: 'canon-ToolbarBreadcrumb',
breadcrumbLink: 'canon-ToolbarBreadcrumbLink',
breadcrumbSeparator: 'canon-ToolbarBreadcrumbSeparator',
tabs: 'canon-ToolbarTabs',
tabList: 'canon-ToolbarTabList',
tab: 'canon-ToolbarTab',