diff --git a/packages/canon/src/components/Toolbar/Toolbar.styles.css b/packages/canon/src/components/Toolbar/Toolbar.styles.css new file mode 100644 index 0000000000..0c432b971c --- /dev/null +++ b/packages/canon/src/components/Toolbar/Toolbar.styles.css @@ -0,0 +1,82 @@ +/* + * 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. + */ + +.canon-ToolbarTabs { + display: flex; + flex-direction: row; + align-items: center; + background-color: var(--canon-bg-surface-1); + position: relative; + + /* Initialize CSS variables */ + --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; +} + +.canon-ToolbarTabList { + display: flex; + flex-direction: row; +} + +.canon-ToolbarTab { + font-size: var(--canon-font-size-3); + color: var(--canon-fg-primary); + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + position: relative; + z-index: 2; +} + +/* Active tab indicator */ +.canon-ToolbarActiveIndicator { + content: ''; + position: absolute; + left: var(--active-tab-left); + top: var(--active-tab-top); + width: var(--active-tab-width); + height: var(--active-tab-height); + background-color: green; + border-radius: 4px; + transition: all 0.25s ease-out; + opacity: 1; +} + +/* Hovered tab indicator */ +.canon-ToolbarHoveredIndicator { + content: ''; + position: absolute; + left: var(--hovered-tab-left); + top: var(--hovered-tab-top); + width: var(--hovered-tab-width); + height: var(--hovered-tab-height); + background-color: red; + border-radius: 4px; + transition: all 0.2s ease-out; +} diff --git a/packages/canon/src/components/Toolbar/Toolbar.tsx b/packages/canon/src/components/Toolbar/Toolbar.tsx new file mode 100644 index 0000000000..d6f5b4d7f3 --- /dev/null +++ b/packages/canon/src/components/Toolbar/Toolbar.tsx @@ -0,0 +1,216 @@ +/* + * 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 { + Tabs, + TabList, + Tab as AriaTab, + TabListStateContext, +} from 'react-aria-components'; +import { useStyles } from '../../hooks/useStyles'; +import { + useId, + useContext, + useRef, + useEffect, + useState, + useCallback, + MutableRefObject, +} from 'react'; +import { ToolbarProps, ToolbarTab } from './types'; + +export const Toolbar = (props: ToolbarProps) => { + const { tabs } = props; + const { classNames } = useStyles('Toolbar'); + const tabsRef = useRef(null); + const tabRefs = useRef>(new Map()); + const [hoveredKey, setHoveredKey] = useState(null); + + const setTabRef = (key: string, element: HTMLDivElement | null) => { + if (element) { + tabRefs.current.set(key, element); + } else { + tabRefs.current.delete(key); + } + }; + + return ( + <> +
Toolbar
+ {tabs && ( + + + {tabs.map((tab, index) => { + return ( + + ); + })} + + + + )} + + ); +}; + +const Tab = ({ + tab, + setTabRef, + setHoveredKey, +}: { + tab: ToolbarTab; + setTabRef: (key: string, element: HTMLDivElement | null) => void; + setHoveredKey: (key: string | null) => void; +}) => { + const id = useId(); + const { classNames } = useStyles('Toolbar'); + + return ( + setTabRef(id, el as HTMLDivElement)} + onHoverStart={() => setHoveredKey(id)} + onHoverEnd={() => setHoveredKey(null)} + > + {tab.label} + + ); +}; + +const Indicator = ({ + tabRefs, + tabsRef, + hoveredKey, +}: { + tabRefs: MutableRefObject>; + tabsRef: MutableRefObject; + hoveredKey: string | null; +}) => { + 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()); + console.log(activeTab); + 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`, + ); + } + } else { + // Clear hovered variables when no tab is hovered + tabsRef.current.style.setProperty('--hovered-tab-left', '0px'); + tabsRef.current.style.setProperty('--hovered-tab-right', '0px'); + tabsRef.current.style.setProperty('--hovered-tab-top', '0px'); + tabsRef.current.style.setProperty('--hovered-tab-bottom', '0px'); + tabsRef.current.style.setProperty('--hovered-tab-width', '0px'); + tabsRef.current.style.setProperty('--hovered-tab-height', '0px'); + } + }, [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 new file mode 100644 index 0000000000..e1c6d67e64 --- /dev/null +++ b/packages/canon/src/components/Toolbar/Tooltip.stories.tsx @@ -0,0 +1,42 @@ +/* + * 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 type { Meta, StoryObj } from '@storybook/react'; +import { Toolbar } from './Toolbar'; + +const meta = { + title: 'Components/Toolbar', + component: Toolbar, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + tabs: [ + { + label: 'Home', + }, + { + label: 'About', + }, + { + label: 'Contact Super long tab name', + }, + ], + }, +}; diff --git a/packages/canon/src/components/Toolbar/index.tsx b/packages/canon/src/components/Toolbar/index.tsx new file mode 100644 index 0000000000..3e26a05f5f --- /dev/null +++ b/packages/canon/src/components/Toolbar/index.tsx @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export * from './Toolbar'; diff --git a/packages/canon/src/components/Toolbar/types.ts b/packages/canon/src/components/Toolbar/types.ts new file mode 100644 index 0000000000..48f5d53c10 --- /dev/null +++ b/packages/canon/src/components/Toolbar/types.ts @@ -0,0 +1,23 @@ +/* + * 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. + */ + +export interface ToolbarTab { + label: string; +} + +export interface ToolbarProps { + tabs: ToolbarTab[]; +} diff --git a/packages/canon/src/css/components.css b/packages/canon/src/css/components.css index 7feb065226..e2656be550 100644 --- a/packages/canon/src/css/components.css +++ b/packages/canon/src/css/components.css @@ -41,6 +41,7 @@ @import '../components/Text/styles.css'; @import '../components/TextField/TextField.styles.css'; @import '../components/SearchField/SearchField.styles.css'; +@import '../components/Toolbar/Toolbar.styles.css'; @import '../components/Tooltip/Tooltip.styles.css'; @import '../components/ScrollArea/ScrollArea.styles.css'; @import '../components/Select/Select.styles.css'; diff --git a/packages/canon/src/utils/componentDefinitions.ts b/packages/canon/src/utils/componentDefinitions.ts index aa941d303c..b23757313d 100644 --- a/packages/canon/src/utils/componentDefinitions.ts +++ b/packages/canon/src/utils/componentDefinitions.ts @@ -235,6 +235,15 @@ export const componentDefinitions = { disabled: [true, false] as const, }, }, + Toolbar: { + classNames: { + tabs: 'canon-ToolbarTabs', + tabList: 'canon-ToolbarTabList', + tab: 'canon-ToolbarTab', + activeIndicator: 'canon-ToolbarActiveIndicator', + hoveredIndicator: 'canon-ToolbarHoveredIndicator', + }, + }, Tooltip: { classNames: { trigger: 'canon-TooltipTrigger',