diff --git a/packages/core/src/components/Tabs/Tab.test.tsx b/packages/core/src/components/Tabs/Tab.test.tsx new file mode 100644 index 0000000000..5b155ef536 --- /dev/null +++ b/packages/core/src/components/Tabs/Tab.test.tsx @@ -0,0 +1,27 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 React from 'react'; +import { render } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { StyledTab } from './Tab'; + +describe('', () => { + it('renders without exploding', () => { + const rendered = render(wrapInTestApp()); + expect(rendered.getByText('test')).toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/components/Tabs/Tab.tsx b/packages/core/src/components/Tabs/Tab.tsx new file mode 100644 index 0000000000..93243b2900 --- /dev/null +++ b/packages/core/src/components/Tabs/Tab.tsx @@ -0,0 +1,62 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 React from 'react'; +import { Tab, makeStyles } from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; + +interface StyledTabProps { + label?: string; + icon?: any; // TODO: define type for material-ui icons + isFirstNav?: boolean; + isFirstIndex?: boolean; + value?: any; +} + +const tabMarginLeft = (isFirstNav: boolean, isFirstIndex: boolean) => { + if (isFirstIndex) { + if (isFirstNav) { + return '20px'; + } + return '0'; + } + return '40px'; +}; + +const useStyles = makeStyles(theme => ({ + root: { + textTransform: 'none', + height: '64px', + fontWeight: theme.typography.fontWeightBold, + fontSize: theme.typography.pxToRem(13), + color: theme.palette.textSubtle, + marginLeft: props => + tabMarginLeft(props.isFirstNav as boolean, props.isFirstIndex as boolean), + width: '130px', + minWidth: '130px', + '&:hover': { + outline: 'none', + backgroundColor: 'transparent', + color: theme.palette.textSubtle, + }, + }, +})); + +export const StyledTab = (props: StyledTabProps) => { + const classes = useStyles(props); + const { isFirstNav, isFirstIndex, ...rest } = props; + return ; +}; diff --git a/packages/core/src/components/Tabs/TabBar.tsx b/packages/core/src/components/Tabs/TabBar.tsx new file mode 100644 index 0000000000..379d60668b --- /dev/null +++ b/packages/core/src/components/Tabs/TabBar.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 React, { FC } from 'react'; +import { Tabs, makeStyles } from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; + +interface StyledTabsProps { + value: number | boolean; + onChange: (event: React.ChangeEvent<{}>, newValue: number) => void; +} + +const useStyles = makeStyles(theme => ({ + indicator: { + display: 'flex', + justifyContent: 'center', + backgroundColor: theme.palette.tabbar.indicator, + height: '4px', + }, + flexContainer: { + alignItems: 'center', + }, + root: { + '&:last-child': { + marginLeft: 'auto', + }, + }, +})); + +export const StyledTabs: FC = props => { + const classes = useStyles(props); + return ( + }} + /> + ); +}; diff --git a/packages/core/src/components/Tabs/TabIcon.tsx b/packages/core/src/components/Tabs/TabIcon.tsx new file mode 100644 index 0000000000..ffb2e12cbd --- /dev/null +++ b/packages/core/src/components/Tabs/TabIcon.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 React from 'react'; +import { IconButton, makeStyles } from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; + +interface StyledIconProps { + ariaLabel: string; + children: any; + isNext?: boolean; + onClick: any; +} + +const useStyles = makeStyles(() => ({ + root: { + color: '#6E6E6E', + overflow: 'visible', + fontSize: '1.5rem', + textAlign: 'center', + borderRadius: '50%', + backgroundColor: '#E6E6E6', + marginLeft: props => (props.isNext ? 'auto' : '0'), + marginRight: props => (props.isNext ? '0' : '10px'), + '&:hover': { + backgroundColor: '#E6E6E6', + opacity: '1', + }, + }, +})); + +export const StyledIcon = (props: StyledIconProps) => { + const classes = useStyles(props); + const { ariaLabel, onClick } = props; + return ( + + {props.children} + + ); +}; diff --git a/packages/core/src/components/Tabs/TabPanel.tsx b/packages/core/src/components/Tabs/TabPanel.tsx new file mode 100644 index 0000000000..ba8ca4bdee --- /dev/null +++ b/packages/core/src/components/Tabs/TabPanel.tsx @@ -0,0 +1,39 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 React, { FC } from 'react'; +import Box from '@material-ui/core/Box'; + +export interface TabPanelProps { + children: any; + value?: any; + index?: number; +} + +export const TabPanel: FC = props => { + const { children, value, index, ...other } = props; + + return ( + + ); +}; diff --git a/packages/core/src/components/Tabs/Tabs.stories.tsx b/packages/core/src/components/Tabs/Tabs.stories.tsx new file mode 100644 index 0000000000..930a825392 --- /dev/null +++ b/packages/core/src/components/Tabs/Tabs.stories.tsx @@ -0,0 +1,71 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 React from 'react'; +import { Tabs } from './Tabs'; +import AccessAlarmIcon from '@material-ui/icons/AccessAlarm'; + +export default { + title: 'Tabs', + component: Tabs, +}; + +const containerStyle = {}; + +export const Default = () => ( +
+ ({ + label: `ANOTHER TAB`, + content:
Content {index}
, + }))} + /> +
+); + +export const Expandable = () => ( +
+ ({ + label: `ANOTHER TAB`, + content:
Content {index}
, + }))} + /> +
+); + +export const Icons = () => ( +
+ ({ + icon: , + content:
Content {index}
, + }))} + /> +
+); + +export const IconsAndLabels = () => ( +
+ ({ + icon: , + label: `ANOTHER TAB`, + content:
Content {index}
, + }))} + /> +
+); diff --git a/packages/core/src/components/Tabs/Tabs.tsx b/packages/core/src/components/Tabs/Tabs.tsx new file mode 100644 index 0000000000..54369de473 --- /dev/null +++ b/packages/core/src/components/Tabs/Tabs.tsx @@ -0,0 +1,165 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 React, { + FC, + useRef, + useEffect, + MutableRefObject, + useState, +} from 'react'; +import { BackstageTheme } from '@backstage/theme'; +import { AppBar } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import NavigateBeforeIcon from '@material-ui/icons/NavigateBefore'; +import NavigateNextIcon from '@material-ui/icons/NavigateNext'; +import { chunkArray } from './utils'; +import { useWindowSize } from 'react-use'; + +/* Import Components */ + +import { TabPanel } from './TabPanel'; +import { StyledIcon } from './TabIcon'; +import { StyledTab } from './Tab'; +import { StyledTabs } from './TabBar'; + +/* Props Types */ + +export interface TabProps { + content: any; + label?: string; + icon?: any; // TODO: define type for material-ui icons +} + +export interface TabsProps { + tabs: TabProps[]; +} + +const useStyles = makeStyles((theme: BackstageTheme) => ({ + root: { + flexGrow: 1, + width: '100%', + }, + styledTabs: { + backgroundColor: theme.palette.background.paper, + }, + appbar: { + boxShadow: 'none', + backgroundColor: theme.palette.background.paper, + paddingLeft: '10px', + paddingRight: '10px', + }, +})); + +export const Tabs: FC = ({ tabs }) => { + const classes = useStyles(); + const [value, setValue] = useState([0, 0]); // [selectedChunckedNavIndex, selectedIndex] + const [navIndex, setNavIndex] = useState(0); + const [numberOfChunkedElement, setNumberOfChunkedElement] = useState(0); + const [chunkedTabs, setChunkedTabs] = useState([[]]); + const wrapper = useRef() as MutableRefObject; + + const { width } = useWindowSize(); + + const handleChange = (_: React.ChangeEvent<{}>, newValue: number) => { + setValue([navIndex, newValue]); + }; + + const navigateToPrevChunk = () => { + setNavIndex(navIndex - 1); + }; + + const navigateToNextChunk = () => { + setNavIndex(navIndex + 1); + }; + + const hasNextNavIndex = () => navIndex + 1 < chunkedTabs.length; + + useEffect(() => { + // Each time the window is resized we calculate how many tabs wwe can render given the window width + const padding = 20; // The AppBar padding + + const numberOfTabIcons = navIndex === 0 ? 1 : 2; + const wrapperWidth = + wrapper.current.offsetWidth - padding - numberOfTabIcons * 30; + const flattenIndex = value[0] * numberOfChunkedElement + value[1]; + const newChunkedElementSize = Math.floor(wrapperWidth / 170); + + setNumberOfChunkedElement(newChunkedElementSize); + setChunkedTabs(chunkArray([...tabs], newChunkedElementSize)); + setValue([ + Math.floor(flattenIndex / newChunkedElementSize), + flattenIndex % newChunkedElementSize, + ]); + // eslint-disable-next-line + }, [width, tabs]); + + const currentIndex = navIndex === value[0] ? value[1] : false; + + return ( +
+ +
+ + {navIndex !== 0 && ( + + + + )} + {chunkedTabs[navIndex].map((tab, index) => ( + + ))} + {hasNextNavIndex() && ( + + + + )} + +
+
+ {currentIndex !== false ? ( + chunkedTabs[navIndex].map((tab, index) => ( + + {tab.content} + + )) + ) : ( + // Render if the selected tab index is outside the current rendered chunked array + + {chunkedTabs[value[0]][value[1]].content} + + )} +
+ ); +}; diff --git a/packages/core/src/components/Tabs/index.ts b/packages/core/src/components/Tabs/index.ts new file mode 100644 index 0000000000..835ab5a8c3 --- /dev/null +++ b/packages/core/src/components/Tabs/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { Tabs as default } from './Tabs'; diff --git a/packages/core/src/components/Tabs/utils.ts b/packages/core/src/components/Tabs/utils.ts new file mode 100644 index 0000000000..3e0ab6f2c3 --- /dev/null +++ b/packages/core/src/components/Tabs/utils.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { TabProps } from './Tabs'; + +export const chunkArray = ( + myArray: TabProps[], + chunkSize: number, +): TabProps[][] => { + const results = []; + while (myArray.length) { + results.push(myArray.splice(0, chunkSize)); + } + return results; +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f3c5c5c778..c5749866b6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -41,3 +41,4 @@ export * from './components/Status'; export * from './components/Button'; export * from './components/Link'; export { default as WarningPanel } from './components/WarningPanel'; +export { default as Tabs } from './components/Tabs'; diff --git a/packages/theme/src/themes.ts b/packages/theme/src/themes.ts index fe3c71a212..3071c0289e 100644 --- a/packages/theme/src/themes.ts +++ b/packages/theme/src/themes.ts @@ -60,6 +60,9 @@ export const lightTheme = createTheme({ icon: '#BDBDBD', background: '#404040', }, + tabbar: { + indicator: '#9BF0E1', + }, }, }); @@ -106,5 +109,8 @@ export const darkTheme = createTheme({ icon: '#181818', background: '#BDBDBD', }, + tabbar: { + indicator: '#9BF0E1', + }, }, }); diff --git a/packages/theme/src/types.ts b/packages/theme/src/types.ts index c38d67d063..fdb9e09869 100644 --- a/packages/theme/src/types.ts +++ b/packages/theme/src/types.ts @@ -44,6 +44,9 @@ type PaletteAdditions = { link: string; gold: string; sidebar: string; + tabbar: { + indicator: string; + }; bursts: { fontColor: string; slackChannelText: string;