diff --git a/packages/canon/src/components/Header/Header.stories.tsx b/packages/canon/src/components/Header/Header.stories.tsx index 411dda14ca..995af95619 100644 --- a/packages/canon/src/components/Header/Header.stories.tsx +++ b/packages/canon/src/components/Header/Header.stories.tsx @@ -20,6 +20,8 @@ import { HeaderBreadcrumb, HeaderOption, HeaderTab } from './types'; import { Button } from '../Button'; import { HeaderPage } from '../HeaderPage'; import { MemoryRouter } from 'react-router-dom'; +import { Container } from '../Container'; +import { Text } from '../Text'; const meta = { title: 'Components/Header', @@ -29,21 +31,32 @@ const meta = { export default meta; type Story = StoryObj; +const withRouter = (Story: StoryFn) => ( + + + +); + const tabs: HeaderTab[] = [ { + id: 'overview', label: 'Overview', }, { + id: 'checks', label: 'Checks', }, { + id: 'tracks', label: 'Tracks', }, { + id: 'campaigns', label: 'Campaigns', href: '/campaigns', }, { + id: 'integrations', label: 'Integrations', href: '/integrations', }, @@ -117,34 +130,40 @@ const layoutDecorator = [ /> ), + withRouter, ]; export const Default: Story = { args: {}, + decorators: [withRouter], }; export const WithTabs: Story = { args: { tabs, }, + decorators: [withRouter], }; export const WithOptions: Story = { args: { menuItems, }, + decorators: [withRouter], }; export const WithCustomActions: Story = { args: { customActions: , }, + decorators: [withRouter], }; export const WithBreadcrumbs: Story = { args: { breadcrumbs, }, + decorators: [withRouter], }; export const WithAllComponents: Story = { @@ -153,6 +172,7 @@ export const WithAllComponents: Story = { tabs, breadcrumbs, }, + decorators: [withRouter], }; export const WithLayout: Story = { @@ -217,15 +237,15 @@ export const WithMockedURLCampaigns: Story = { render: args => (
-
-

+ + Current URL is mocked to be: /campaigns -

-

+ + Notice how the "Campaigns" tab is selected (highlighted) because it matches the current path. -

-
+ + ), }; diff --git a/packages/canon/src/components/Header/Header.styles.css b/packages/canon/src/components/Header/Header.styles.css index 3a799675e9..e88545ae14 100644 --- a/packages/canon/src/components/Header/Header.styles.css +++ b/packages/canon/src/components/Header/Header.styles.css @@ -133,6 +133,7 @@ --active-tab-bottom: 0px; --active-tab-width: 0px; --active-tab-height: 0px; + --active-transition-duration: 0s; --hovered-tab-left: 0px; --hovered-tab-right: 0px; @@ -175,7 +176,8 @@ height: 1px; background-color: var(--canon-fg-primary); border-radius: 4px; - transition: all 0.25s ease-out; + transition: left var(--active-transition-duration) ease-out, + opacity 0.15s ease-out; opacity: 1; } diff --git a/packages/canon/src/components/Header/Header.tsx b/packages/canon/src/components/Header/Header.tsx index 446c84d6f7..26a3b066d7 100644 --- a/packages/canon/src/components/Header/Header.tsx +++ b/packages/canon/src/components/Header/Header.tsx @@ -17,7 +17,14 @@ import type { HeaderProps } from './types'; import { HeaderToolbar } from './HeaderToolbar'; import { HeaderTabs } from './HeaderTabs'; -import { Item } from 'react-stately'; +import { RouterProvider } from 'react-aria-components'; +import { type NavigateOptions, useHref, useNavigate } from 'react-router-dom'; + +declare module 'react-aria-components' { + interface RouterConfig { + routerOptions: NavigateOptions; + } +} /** * A component that renders a toolbar. @@ -26,11 +33,12 @@ import { Item } from 'react-stately'; */ export const Header = (props: HeaderProps) => { const { tabs, icon, title, menuItems, breadcrumbs, customActions } = props; + let navigate = useNavigate(); const hasTabs = tabs && tabs.length > 0; return ( - <> + { customActions={customActions} hasTabs={hasTabs} /> - {tabs && ( - - {tabs.map(tab => ( - - {tab.label} - - ))} - - )} - + + ); }; diff --git a/packages/canon/src/components/Header/HeaderTabs.tsx b/packages/canon/src/components/Header/HeaderTabs.tsx index ac201702ac..ddc1f59ff3 100644 --- a/packages/canon/src/components/Header/HeaderTabs.tsx +++ b/packages/canon/src/components/Header/HeaderTabs.tsx @@ -14,29 +14,28 @@ * limitations under the License. */ -import { useTabList, useTab } from 'react-aria'; -import { useTabListState } from 'react-stately'; -import { useLocation } from 'react-router-dom'; +import { Tabs, TabList, Tab as AriaTab } from 'react-aria-components'; import { useStyles } from '../../hooks/useStyles'; import { useRef, useState } from 'react'; import { HeaderTabsIndicators } from './HeaderTabsIndicators'; -import type { HeaderTabProps, HeaderTabsProps } from './types'; +import type { HeaderProps, HeaderTabProps } from './types'; +import { useLocation } from 'react-router-dom'; /** * A component that renders header tabs. * * @internal */ -export const HeaderTabs = (props: HeaderTabsProps) => { +export const HeaderTabs = (props: HeaderProps) => { + const { tabs } = props; const { classNames } = useStyles('Header'); const tabsRef = useRef(null); const tabRefs = useRef>(new Map()); const [hoveredKey, setHoveredKey] = useState(null); const prevHoveredKey = useRef(null); + const location = useLocation(); - let state = useTabListState(props); - let ref = useRef(null); - const { tabListProps } = useTabList({}, state, ref); + const selectedKey = tabs?.find(tab => tab.href === location.pathname)?.id; const setTabRef = (key: string, element: HTMLDivElement | null) => { if (element) { @@ -46,27 +45,35 @@ export const HeaderTabs = (props: HeaderTabsProps) => { } }; + if (!tabs) return null; + return ( -
-
- {Array.from(state.collection).map(item => ( - - ))} -
+ + + {tabs.map((tab, index) => { + return ( + + ); + })} + -
+ ); }; @@ -75,36 +82,20 @@ export const HeaderTabs = (props: HeaderTabsProps) => { * * @internal */ -function Tab({ item, state, setTabRef, setHoveredKey }: HeaderTabProps) { - let { key, rendered } = item; +const Tab = (props: HeaderTabProps) => { + const { tab, setTabRef, setHoveredKey, id } = props; const { classNames } = useStyles('Header'); - const location = useLocation(); - - let ref = useRef(null); - let { tabProps } = useTab({ key }, state, ref); - - const setRef = (el: HTMLDivElement | null) => { - // Set the ref for React Aria - use more specific type assertion - (ref as React.MutableRefObject).current = el; - // Set the ref for tracking tab elements - setTabRef(key.toString(), el); - }; - - // Check if the current path matches the tab's href - const isSelected = item.props?.href - ? location.pathname === item.props.href - : state.selectedKey === key; return ( -
setHoveredKey(key.toString())} - onMouseLeave={() => setHoveredKey(null)} - {...tabProps} + ref={el => setTabRef(id, el as HTMLDivElement)} + onHoverStart={() => setHoveredKey(id)} + onHoverEnd={() => setHoveredKey(null)} + href={tab.href} > - {rendered} -
+ {tab.label} + ); -} +}; diff --git a/packages/canon/src/components/Header/HeaderTabsIndicators.tsx b/packages/canon/src/components/Header/HeaderTabsIndicators.tsx index 92e8cf92c3..9628809f3f 100644 --- a/packages/canon/src/components/Header/HeaderTabsIndicators.tsx +++ b/packages/canon/src/components/Header/HeaderTabsIndicators.tsx @@ -14,26 +14,28 @@ * limitations under the License. */ +import { TabListStateContext } from 'react-aria-components'; import { useStyles } from '../../hooks/useStyles'; -import { useEffect, useCallback } from 'react'; +import { useContext, useEffect, useCallback, useRef } from 'react'; import type { HeaderIndicatorsProps } from './types'; /** * A component that renders the indicators for the toolbar. - * Uses React Aria's TabListState passed as a prop. * * @internal */ export const HeaderTabsIndicators = (props: HeaderIndicatorsProps) => { - const { tabRefs, tabsRef, hoveredKey, prevHoveredKey, state } = props; + const { tabRefs, tabsRef, hoveredKey, prevHoveredKey } = props; const { classNames } = useStyles('Header'); + const state = useContext(TabListStateContext); + const prevSelectedKey = useRef(null); const updateCSSVariables = useCallback(() => { if (!tabsRef.current) return; const tabsRect = tabsRef.current.getBoundingClientRect(); - // Set active tab variables using React Aria's selectedKey from state + // Set active tab variables if (state?.selectedKey) { const activeTab = tabRefs.current.get(state.selectedKey.toString()); @@ -42,7 +44,35 @@ export const HeaderTabsIndicators = (props: HeaderIndicatorsProps) => { const relativeLeft = activeRect.left - tabsRect.left; const relativeTop = activeRect.top - tabsRect.top; - // Set CSS variables for the active tab indicator position + // Control transition timing based on whether this is the first time setting active tab + const isFirstActiveTab = prevSelectedKey.current === null; + + if (isFirstActiveTab) { + // First time setting active tab: no transitions for position + tabsRef.current.style.setProperty( + '--active-transition-duration', + '0s', + ); + // Enable transitions on next frame for future tab switches + requestAnimationFrame(() => { + if (tabsRef.current) { + tabsRef.current.style.setProperty( + '--active-transition-duration', + '0.25s', + ); + } + }); + } else { + // Switching between tabs: full transitions + tabsRef.current.style.setProperty( + '--active-transition-duration', + '0.25s', + ); + } + + // Update previous selected key for next time + prevSelectedKey.current = state.selectedKey.toString(); + tabsRef.current.style.setProperty( '--active-tab-left', `${relativeLeft}px`, @@ -70,7 +100,7 @@ export const HeaderTabsIndicators = (props: HeaderIndicatorsProps) => { } } - // Set hovered tab variables (separate from React Aria state) + // Set hovered tab variables if (hoveredKey) { const hoveredTab = tabRefs.current.get(hoveredKey); if (hoveredTab) { @@ -102,7 +132,6 @@ export const HeaderTabsIndicators = (props: HeaderIndicatorsProps) => { '--hovered-tab-height', `${hoveredRect.height}px`, ); - // Control transition timing based on whether this is a new hover session const isNewHoverSession = prevHoveredKey.current === null; @@ -141,11 +170,11 @@ export const HeaderTabsIndicators = (props: HeaderIndicatorsProps) => { // Reset previous hover key so next hover is treated as new session prevHoveredKey.current = null; } - }, [state?.selectedKey, hoveredKey]); // React Aria's selectedKey drives active tab updates + }, [state?.selectedKey, hoveredKey, tabRefs.current]); useEffect(() => { updateCSSVariables(); - }, [updateCSSVariables]); + }, [updateCSSVariables, tabRefs.current.size]); useEffect(() => { const handleResize = () => updateCSSVariables(); diff --git a/packages/canon/src/components/Header/types.ts b/packages/canon/src/components/Header/types.ts index 79f1579d08..f4b45c87a0 100644 --- a/packages/canon/src/components/Header/types.ts +++ b/packages/canon/src/components/Header/types.ts @@ -15,8 +15,6 @@ */ import { MutableRefObject } from 'react'; -import { TabListProps, TabListState } from '@react-stately/tabs'; -import { CollectionChildren, Node } from '@react-types/shared'; /** * Props for the main Header component. @@ -38,6 +36,7 @@ export interface HeaderProps { * @public */ export interface HeaderTab { + id: string; label: string; href?: string; } @@ -69,13 +68,12 @@ export interface HeaderBreadcrumb { * @public */ export interface HeaderTabProps { - item: Node; - state: TabListState; + id: string; + tab: HeaderTab; setTabRef: (key: string, element: HTMLDivElement | null) => void; setHoveredKey: (key: string | null) => void; } -/** @internal */ export interface HeaderToolbarProps { icon?: HeaderProps['icon']; title?: HeaderProps['title']; @@ -85,18 +83,9 @@ export interface HeaderToolbarProps { hasTabs?: boolean; } -/** @internal */ -export interface HeaderTabsProps - extends Omit, 'children'> { - children?: CollectionChildren; - keyboardActivation?: 'automatic' | 'manual'; -} - -/** @internal */ export interface HeaderIndicatorsProps { tabRefs: MutableRefObject>; tabsRef: MutableRefObject; hoveredKey: string | null; prevHoveredKey: MutableRefObject; - state: TabListState; }