From 0635f774af9dfd03fcc8f7adfcd32591c86efa25 Mon Sep 17 00:00:00 2001 From: Shinji Date: Wed, 25 Sep 2024 13:16:07 +0000 Subject: [PATCH] feat: implement scrollable variant for tabs component --- .../src/components/Tabs/Tabs.stories.tsx | 35 ++- widget/storybook/src/components/Tabs/mock.tsx | 95 +++++++ widget/ui/src/components/Tabs/Tabs.styles.tsx | 52 +++- widget/ui/src/components/Tabs/Tabs.tsx | 234 +++++++++++++----- widget/ui/src/components/Tabs/Tabs.types.tsx | 2 + 5 files changed, 356 insertions(+), 62 deletions(-) diff --git a/widget/storybook/src/components/Tabs/Tabs.stories.tsx b/widget/storybook/src/components/Tabs/Tabs.stories.tsx index 155c494091..dcc3d359eb 100644 --- a/widget/storybook/src/components/Tabs/Tabs.stories.tsx +++ b/widget/storybook/src/components/Tabs/Tabs.stories.tsx @@ -4,7 +4,7 @@ import type { Meta } from '@storybook/react'; import { Tabs } from '@rango-dev/ui'; import React, { useState } from 'react'; -import { themes } from './mock'; +import { numbers, themes } from './mock'; export default { title: 'Components/Tabs', @@ -19,9 +19,9 @@ export default { argTypes: { type: { control: { type: 'select' }, - options: ['primary', 'secondary'], + options: ['primary', 'secondary', 'bordered'], defaultValue: 'primary', - description: 'primary | secondary | undefined', + description: 'primary | secondary | bordered | undefined', }, className: { control: { type: 'text' }, @@ -36,6 +36,14 @@ export default { onChange: { type: 'function', }, + scrollable: { + defaultValue: false, + type: 'boolean', + }, + scrollButtons: { + defaultValue: true, + type: 'boolean', + }, }, } as Meta; @@ -56,3 +64,24 @@ export const Main = (args: TabsPropTypes) => { ); }; + +export const Scrollable = (args: TabsPropTypes) => { + const [value, setValue] = useState(numbers[0].id); + + return ( +
+ setValue(item.id as string)} + /> +
+ ); +}; diff --git a/widget/storybook/src/components/Tabs/mock.tsx b/widget/storybook/src/components/Tabs/mock.tsx index 8b7521d491..0e764c043e 100644 --- a/widget/storybook/src/components/Tabs/mock.tsx +++ b/widget/storybook/src/components/Tabs/mock.tsx @@ -1,3 +1,5 @@ +import type { TabsPropTypes } from '@rango-dev/ui'; + import { AutoThemeIcon, DarkModeIcon, @@ -35,3 +37,96 @@ export const themes = [ ), }, ]; + +export const numbers: TabsPropTypes['items'] = [ + { + id: 'one', + title: 'one', + tooltip: ( + + 1 + + ), + }, + { + id: 'two', + title: 'two', + tooltip: ( + + 2 + + ), + }, + { + id: 'three', + title: 'three', + tooltip: ( + + 3 + + ), + }, + { + id: 'four', + title: 'four', + tooltip: ( + + 4 + + ), + }, + { + id: 'five', + title: 'five', + tooltip: ( + + 5 + + ), + }, + { + id: 'six', + title: 'six', + tooltip: ( + + 6 + + ), + }, + { + id: 'seven', + title: 'seven', + tooltip: ( + + 7 + + ), + }, + { + id: 'eight', + title: 'eight', + tooltip: ( + + 8 + + ), + }, + { + id: 'nine', + title: 'nine', + tooltip: ( + + 9 + + ), + }, + { + id: 'ten', + title: 'ten', + tooltip: ( + + 10 + + ), + }, +]; diff --git a/widget/ui/src/components/Tabs/Tabs.styles.tsx b/widget/ui/src/components/Tabs/Tabs.styles.tsx index cf6b26dd75..0e63a01761 100644 --- a/widget/ui/src/components/Tabs/Tabs.styles.tsx +++ b/widget/ui/src/components/Tabs/Tabs.styles.tsx @@ -1,5 +1,39 @@ import { darkTheme, styled } from '../../theme.js'; import { Button } from '../Button/index.js'; +import { IconButton } from '../IconButton/IconButton.js'; + +export const Container = styled('div', { + position: 'relative', + variants: { + hasPadding: { + true: { + padding: '0 $32', + }, + }, + }, +}); + +export const Arrow = styled(IconButton, { + position: 'absolute', + height: '100%', + zIndex: 20, + borderRadius: '$xs !important', + variants: { + hidden: { + true: { + visibility: 'hidden', + }, + }, + }, +}); + +export const ArrowRight = styled(Arrow, { + right: 0, +}); + +export const ArrowLeft = styled(Arrow, { + left: 0, +}); export const Tabs = styled('div', { display: 'flex', @@ -23,7 +57,7 @@ export const Tabs = styled('div', { borderStyle: 'solid', backgroundColor: '$$color', }, - bordered: { backgroundColor: 'inherit' }, + bordered: {}, }, borderRadius: { small: { @@ -39,6 +73,16 @@ export const Tabs = styled('div', { borderRadius: 'unset', }, }, + scrollable: { + true: { + overflowX: 'auto', + scrollBehavior: 'smooth', + scrollbarWidth: 'none', + '&::webkit-scrollbar': { + display: 'none', + }, + }, + }, }, }); @@ -75,7 +119,9 @@ export const Tab = styled(Button, { height: '100%', }, secondary: {}, - bordered: {}, + bordered: { + padding: '$10 $20', + }, }, isActive: { true: { @@ -154,6 +200,7 @@ export const Tab = styled(Button, { export const BackdropTab = styled('div', { padding: '$4', + boxSizing: 'border-box', position: 'absolute', inset: 0, transition: 'transform 0.2s cubic-bezier(0, 0, 0.86, 1.2)', @@ -169,6 +216,7 @@ export const BackdropTab = styled('div', { backgroundColor: '$background', }, bordered: { + borderRadius: '0 !important', borderBottom: '$secondary500 solid 2px', }, }, diff --git a/widget/ui/src/components/Tabs/Tabs.tsx b/widget/ui/src/components/Tabs/Tabs.tsx index e9049ab522..a0af79e2fb 100644 --- a/widget/ui/src/components/Tabs/Tabs.tsx +++ b/widget/ui/src/components/Tabs/Tabs.tsx @@ -1,11 +1,19 @@ import type { TabsPropTypes } from './Tabs.types.js'; import React, { useEffect, useRef, useState } from 'react'; +import { ChevronLeftIcon, ChevronRightIcon } from 'src/icons/index.js'; import { Divider } from '../Divider/index.js'; import { Tooltip } from '../Tooltip/index.js'; -import { BackdropTab, Tab, Tabs } from './Tabs.styles.js'; +import { + ArrowLeft, + ArrowRight, + BackdropTab, + Container, + Tab, + Tabs, +} from './Tabs.styles.js'; const INITIAL_RENDER_DELAY = 100; export function TabsComponent(props: TabsPropTypes) { @@ -16,34 +24,68 @@ export function TabsComponent(props: TabsPropTypes) { value, type, className, + scrollable, + scrollButtons = true, } = props; const [tabWidth, setTabWidth] = useState(0); - const tabRef: React.Ref = useRef(null); + const tabRef = useRef(null); + const containerRef = useRef(null); + const leftArrowRef = useRef(null); const currentIndex = tabItems.findIndex((item) => item.id === value); - // State variable to track the initial render const [initialRender, setInitialRender] = useState(true); - const transformPosition = currentIndex * tabWidth; + const [transformPosition, setTransformPosition] = useState(0); + const [leftArrowDisabled, setLeftArrowDisabled] = useState(true); + const [rightArrowDisabled, setRightArrowDisabled] = useState(false); + const [showArrows, setShowArrows] = useState(false); + let borderRadius: TabsPropTypes['borderRadius'] = 'medium'; if (type === 'bordered') { borderRadius = 'none'; - } else if (props.borderRadius) { + } else if (type && props.borderRadius) { borderRadius = props.borderRadius; } - useEffect(() => { - const updateTabWidth = () => { - if (tabRef.current) { - const tabRect = tabRef.current.getBoundingClientRect(); - setTabWidth(tabRect.width); + const handleScroll = (to: 'right' | 'left') => { + if (!containerRef.current) { + return; + } + const SCROLL_MULTIPLIER = 1.5; + const scrollWidth = + (containerRef.current.scrollWidth / tabItems.length) * SCROLL_MULTIPLIER; + + if (to === 'right') { + containerRef.current.scrollLeft -= scrollWidth; + } else { + containerRef.current.scrollLeft += scrollWidth; + } + }; + + const updateIndicator = (currentIndex: number) => { + if (tabRef.current && containerRef.current) { + const tabRect = tabRef.current.getBoundingClientRect(); + const containerRect = containerRef.current.getBoundingClientRect(); + setTabWidth(tabRect.width); + setTransformPosition( + scrollable ? tabRef.current.offsetLeft : currentIndex * tabWidth + ); + const itemPartiallyVisibleOnLeft = tabRect.left < containerRect.left; + const itemPartiallyVisibleOnRight = tabRect.right > containerRect.right; + + if (itemPartiallyVisibleOnLeft) { + containerRef.current.scrollLeft = tabRef.current.offsetLeft; + } else if (itemPartiallyVisibleOnRight) { + const containerComputedStyles = window.getComputedStyle( + containerRef.current + ); + containerRef.current.scrollLeft = + containerRef.current.scrollLeft + + tabRect.right - + containerRect.right + + parseFloat(containerComputedStyles.borderRightWidth); } - }; - updateTabWidth(); - window.addEventListener('resize', updateTabWidth); - return () => { - window.removeEventListener('resize', updateTabWidth); - }; - }, []); + } + }; useEffect(() => { // Set initialRender to false after a short delay @@ -53,48 +95,126 @@ export function TabsComponent(props: TabsPropTypes) { }, INITIAL_RENDER_DELAY); }, []); + useEffect(() => { + updateIndicator(currentIndex); + + const updateArrowsVisibility = () => { + if (scrollable && containerRef.current) { + const startOfTheScroll = containerRef.current.scrollLeft === 0; + const endOfTheScroll = + containerRef.current.scrollLeft + containerRef.current.clientWidth === + containerRef.current.scrollWidth; + + if (startOfTheScroll) { + setLeftArrowDisabled(true); + } else { + setLeftArrowDisabled(false); + } + if (endOfTheScroll) { + setRightArrowDisabled(true); + } else { + setRightArrowDisabled(false); + } + } + }; + + const resizeHandler: ResizeObserverCallback = (event) => { + if (scrollable && containerRef.current) { + const TOTAL_WIDTH_OF_ARROWS = 64; + updateIndicator(currentIndex); + updateArrowsVisibility(); + const element = event[0].target; + if (showArrows) { + const tabsContainerOverflown = + element.scrollWidth - TOTAL_WIDTH_OF_ARROWS > element.clientWidth; + if (!tabsContainerOverflown) { + setShowArrows(false); + } + } else if (element.scrollWidth > element.clientWidth) { + setShowArrows(true); + } + } + }; + + containerRef.current?.addEventListener('scroll', updateArrowsVisibility); + + const resizeObserver = new ResizeObserver(resizeHandler); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + return () => { + if (containerRef.current) { + resizeObserver.unobserve(containerRef.current); + containerRef.current.removeEventListener( + 'scroll', + updateArrowsVisibility + ); + } + }; + }, [containerRef.current, showArrows, currentIndex]); + return ( - - {tabItems.map((item, index) => ( - - onChange(item)} - size="small" - isActive={item.id === value} - variant="default"> - {item.icon} - {!!item.icon && !!item.title && ( - - )} - {item.title} - - - ))} - + {scrollable && showArrows && scrollButtons && ( + <> + + + + )} + - + scrollable={scrollable}> + {tabItems.map((item, index) => ( + + onChange(item)} + size="small" + isActive={item.id === value} + variant="default"> + {item.icon} + {!!item.icon && !!item.title && ( + + )} + {item.title} + + + ))} + + + ); } diff --git a/widget/ui/src/components/Tabs/Tabs.types.tsx b/widget/ui/src/components/Tabs/Tabs.types.tsx index 657c68e7dc..7f72ec536c 100644 --- a/widget/ui/src/components/Tabs/Tabs.types.tsx +++ b/widget/ui/src/components/Tabs/Tabs.types.tsx @@ -29,4 +29,6 @@ export interface TabsPropTypes { type: BaseType; borderRadius?: BaseBorderRadius; className?: string; + scrollable?: boolean; + scrollButtons?: boolean; }