diff --git a/.changeset/silent-grapes-live.md b/.changeset/silent-grapes-live.md new file mode 100644 index 0000000000..72c772cc5f --- /dev/null +++ b/.changeset/silent-grapes-live.md @@ -0,0 +1,5 @@ +--- +"@kadena/react-ui": patch +--- + +Add new tabs component style and added the pagination option diff --git a/packages/libs/react-ui/src/components/Button/Button.css.ts b/packages/libs/react-ui/src/components/Button/Button.css.ts index db114e1548..6f6589bfa6 100644 --- a/packages/libs/react-ui/src/components/Button/Button.css.ts +++ b/packages/libs/react-ui/src/components/Button/Button.css.ts @@ -1,8 +1,10 @@ -import { createVar, style } from '@vanilla-extract/css'; +import { createVar, layer, style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; import { token, tokens, uiBaseBold, uiSmallestBold } from '../../styles'; import { atoms } from '../../styles/atoms.css'; +const defaults = layer('defaults'); + export const hoverBackgroundColor = createVar(); export const disabledBackgroundColor = createVar(); export const focusBackgroundColor = createVar(); @@ -141,39 +143,43 @@ const inverseSelectors = { }; export const buttonReset = style({ - position: 'relative', - appearance: 'button', - WebkitAppearance: 'button', - paddingInline: 0, - /* Remove the inheritance of text transform on button in Edge, Firefox, and IE. */ - textTransform: 'none', - WebkitFontSmoothing: 'antialiased', - /* Font smoothing for Firefox */ - MozOsxFontSmoothing: 'grayscale', - verticalAlign: 'top', - /* prevent touch scrolling on buttons */ - touchAction: 'none', - userSelect: 'none', - cursor: 'pointer', - textDecoration: 'none', - isolation: 'isolate', - border: 'none', - ':focus': { - outline: 'none', - }, - ':focus-visible': { - zIndex: 3, - }, - selectors: { - /* Fix Firefox */ - '&::-moz-focus-inner': { - border: 0, - /* Remove the inner border and padding for button in Firefox. */ - borderStyle: 'none', - padding: 0, - /* Use uppercase PX so values don't get converted to rem */ - marginBlockStart: '-2PX', - marginBlockEnd: '-2PX', + '@layer': { + [defaults]: { + position: 'relative', + appearance: 'button', + WebkitAppearance: 'button', + paddingInline: 0, + /* Remove the inheritance of text transform on button in Edge, Firefox, and IE. */ + textTransform: 'none', + WebkitFontSmoothing: 'antialiased', + /* Font smoothing for Firefox */ + MozOsxFontSmoothing: 'grayscale', + verticalAlign: 'top', + /* prevent touch scrolling on buttons */ + touchAction: 'none', + userSelect: 'none', + cursor: 'pointer', + textDecoration: 'none', + isolation: 'isolate', + border: 'none', + ':focus': { + outline: 'none', + }, + ':focus-visible': { + zIndex: 3, + }, + selectors: { + /* Fix Firefox */ + '&::-moz-focus-inner': { + border: 0, + /* Remove the inner border and padding for button in Firefox. */ + borderStyle: 'none', + padding: 0, + /* Use uppercase PX so values don't get converted to rem */ + marginBlockStart: '-2PX', + marginBlockEnd: '-2PX', + }, + }, }, }, }); @@ -190,6 +196,7 @@ export const button = recipe({ paddingBlock: 'sm', }), { + minWidth: 'fit-content', color: textColor, backgroundColor: backgroundColor, transition: diff --git a/packages/libs/react-ui/src/components/Tabs/Tab.tsx b/packages/libs/react-ui/src/components/Tabs/Tab.tsx index 801deacf71..5f08f8e544 100644 --- a/packages/libs/react-ui/src/components/Tabs/Tab.tsx +++ b/packages/libs/react-ui/src/components/Tabs/Tab.tsx @@ -1,32 +1,74 @@ +import { MonoClose } from '@kadena/react-icons/system'; +import classNames from 'classnames'; import type { ReactNode } from 'react'; import React, { useRef } from 'react'; import type { AriaTabProps } from 'react-aria'; -import { useTab } from 'react-aria'; +import { mergeProps, useHover, useTab } from 'react-aria'; import type { Node, TabListState } from 'react-stately'; -import { tabItemClass } from './Tabs.css'; +import { Button } from '../Button'; +import { closeButtonClass, tabItemClass } from './Tabs.css'; interface ITabProps extends AriaTabProps { item: Node; state: TabListState; + inverse?: boolean; + className?: string; + borderPosition: 'top' | 'bottom'; + onClose?: (item: Node) => void; + isCompact?: boolean; } /** * @internal this should not be used, check the Tabs.stories */ -export const Tab = ({ item, state }: ITabProps): ReactNode => { +export const Tab = ({ + item, + state, + className, + inverse = false, + borderPosition = 'bottom', + isCompact = false, + onClose, +}: ITabProps): ReactNode => { const { key, rendered } = item; const ref = useRef(null); const { tabProps } = useTab({ key }, state, ref); + const { hoverProps, isHovered } = useHover({ ...item, ...state }); return (
{rendered} + {typeof onClose === 'function' && ( + + )}
); }; diff --git a/packages/libs/react-ui/src/components/Tabs/TabPanel.tsx b/packages/libs/react-ui/src/components/Tabs/TabPanel.tsx index f7d5bd62a6..907cce8af9 100644 --- a/packages/libs/react-ui/src/components/Tabs/TabPanel.tsx +++ b/packages/libs/react-ui/src/components/Tabs/TabPanel.tsx @@ -1,3 +1,4 @@ +import classNames from 'classnames'; import type { ReactNode } from 'react'; import React, { useRef } from 'react'; import type { AriaTabPanelProps } from 'react-aria'; @@ -7,6 +8,7 @@ import { tabContentClass } from './Tabs.css'; interface ITabPanelProps extends AriaTabPanelProps { state: TabListState; + className?: string; } /** @@ -17,7 +19,11 @@ export const TabPanel = ({ state, ...props }: ITabPanelProps): ReactNode => { const { tabPanelProps } = useTabPanel(props, state, ref); return ( -
+
{state.selectedItem?.props.children}
); diff --git a/packages/libs/react-ui/src/components/Tabs/Tabs.css.ts b/packages/libs/react-ui/src/components/Tabs/Tabs.css.ts index df3d8923a6..173ed83231 100644 --- a/packages/libs/react-ui/src/components/Tabs/Tabs.css.ts +++ b/packages/libs/react-ui/src/components/Tabs/Tabs.css.ts @@ -1,97 +1,174 @@ -import { style } from '@vanilla-extract/css'; +import { globalStyle, style, styleVariants } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; +import { token, uiBaseBold, uiSmallestBold } from '../../styles'; import { atoms } from '../../styles/atoms.css'; -import { tokens } from '../../styles/tokens/contract.css'; export const tabsContainerClass = style([ atoms({ display: 'flex', flexDirection: 'column', width: '100%', + position: 'relative', }), ]); -export const tabListWrapperClass = style([ +export const scrollContainer = style([ atoms({ overflowX: 'auto', - }), - { - maxWidth: '100%', - paddingLeft: '2px', - paddingTop: '2px', // For focus ring - }, -]); - -export const tabListClass = style([ - atoms({ - display: 'inline-flex', + display: 'flex', flexDirection: 'row', position: 'relative', }), { - minWidth: '100%', + scrollbarWidth: 'none', + paddingTop: '2px', // For focus ring selectors: { - '&::before': { - position: 'absolute', - display: 'block', - content: '', - bottom: '0', - left: '0', - right: '0', - height: tokens.kda.foundation.border.width.normal, - backgroundColor: tokens.kda.foundation.color.border.base.default, + '&.paginationLeft:not(.paginationRight)': { + maskImage: + 'linear-gradient(90deg,rgba(255,255,255,0) 32px, rgba(255,255,255,1) 64px)', + }, + '&.paginationRight:not(.paginationLeft)': { + maskImage: + 'linear-gradient(90deg,rgba(255,255,255,1) calc(100% - 32px),transparent)', + }, + '&.paginationLeft.paginationRight': { + maskImage: + 'linear-gradient(90deg,rgba(255,255,255,0),rgba(255,255,255,0) 32px,rgba(255,255,255,1) 96px,rgba(255,255,255,1) calc(100% - 32px), transparent)', }, }, }, ]); -export const tabItemClass = style([ +export const tabListControls = style([ atoms({ - border: 'none', - cursor: 'pointer', - paddingBlock: 'xs', - paddingInline: 'sm', - fontSize: 'md', - fontWeight: 'secondaryFont.bold', - backgroundColor: 'transparent', - color: 'text.base.default', - outline: 'none', - zIndex: 1, + display: 'flex', + flexDirection: 'row', + position: 'relative', + width: '100%', }), { - opacity: '.6', - whiteSpace: 'nowrap', - selectors: { - '&[data-selected="true"]': { - opacity: '1', - color: tokens.kda.foundation.color.text.brand.primary.default, - }, - '.focusVisible &:focus-visible': { - borderTopLeftRadius: tokens.kda.foundation.radius.sm, - borderTopRightRadius: tokens.kda.foundation.radius.sm, - outline: `2px solid ${tokens.kda.foundation.color.accent.brand.primary}`, - }, + maxWidth: '100%', + ':before': { + content: '""', + position: 'absolute', + bottom: 0, + right: 0, + left: 0, + borderBottom: `2px solid ${token('color.border.base.subtle')}`, }, }, ]); -export const selectorLine = style([ +export const tabListClass = style([ atoms({ - position: 'absolute', - display: 'block', - height: '100%', - bottom: 0, - borderStyle: 'solid', + display: 'inline-flex', + flexDirection: 'row', }), { - width: 0, - borderWidth: 0, - borderBottomWidth: tokens.kda.foundation.border.width.normal, - borderColor: tokens.kda.foundation.color.accent.brand.primary, - transition: 'transform .4s ease, width .4s ease', - transform: `translateX(0)`, + minWidth: '100%', }, ]); +// Prevent button from increasing the tab size and having the outline conflict with label +globalStyle(`${tabListClass} button`, { + paddingBlock: 0, +}); + +globalStyle(`${tabListClass} span`, { + paddingInline: 0, +}); + +export const tabItemClass = recipe({ + base: [ + atoms({ + display: 'flex', + alignItems: 'center', + cursor: 'pointer', + paddingBlock: 'n2', + paddingInline: 'n4', + gap: 'n2', + backgroundColor: 'transparent', + color: 'text.base.default', + outline: 'none', + }), + { + zIndex: 3, + minWidth: 'fit-content', + borderBlock: `2px solid transparent`, + borderTopLeftRadius: token('radius.xs'), + borderTopRightRadius: token('radius.xs'), + transition: + 'background-color .4s ease, color .4s, border-bottom .4s ease-in-out', + whiteSpace: 'nowrap', + selectors: { + '&[data-selected="true"]': { + backgroundColor: token('color.background.base.@active'), + color: token('color.text.base.@active'), + }, + '&[data-hovered="true"]': { + color: token('color.text.base.@hover'), + }, + '.focusVisible &:focus-visible': { + outline: `2px solid ${token('color.border.tint.outline')}`, + borderRadius: token('radius.xs'), + outlineOffset: '-2px', + }, + '&.closeable': { paddingInlineEnd: token('size.n2') }, + }, + }, + ], + variants: { + inverse: { + true: { + selectors: { + '&[data-hovered="true"]': { + backgroundColor: token('color.background.base.@hover'), + }, + '&[data-selected="true"]': { + backgroundColor: token('color.background.base.default'), + }, + }, + }, + false: { + selectors: { + '&[data-hovered="true"]': { + backgroundColor: token('color.background.base.@hover'), + }, + '&[data-selected="true"]': { + backgroundColor: token('color.background.base.@active'), + }, + }, + }, + }, + borderPosition: { + top: { + selectors: { + '&[data-selected="true"]': { + borderTop: `2px solid ${token('color.border.tint.@focus')}`, + }, + '&[data-hovered="true"]:not(&[data-selected="true"])': { + borderTop: `2px solid ${token('color.border.tint.outline')}`, + }, + }, + }, + bottom: { + selectors: { + '&[data-selected="true"]': { + borderBottom: `2px solid ${token('color.border.tint.@focus')}`, + }, + '&[data-hovered="true"]:not(&[data-selected="true"])': { + borderBottom: `2px solid ${token('color.border.tint.outline')}`, + }, + }, + }, + }, + size: { + default: uiBaseBold, + compact: uiSmallestBold, + }, + }, +}); + export const tabContentClass = style([ atoms({ marginBlock: 'md', @@ -101,3 +178,41 @@ export const tabContentClass = style([ overflowY: 'auto', }), ]); + +const paginationButtonBase = style({ + zIndex: 3, + opacity: 1, + transition: 'opacity 0.4s ease, background 0.4s ease', + backgroundColor: 'inherit', +}); + +export const paginationButton = styleVariants({ + left: [ + paginationButtonBase, + atoms({ position: 'absolute', left: 0, top: 0, bottom: 0 }), + ], + right: [paginationButtonBase], +}); + +export const hiddenClass = style({ + opacity: 0, + transition: 'opacity 0.4s ease', + pointerEvents: 'none', +}); + +export const closeButtonClass = style({ + paddingBlock: token('size.n1'), + opacity: 0, + outlineOffset: '-2px', + cursor: 'pointer', + selectors: { + '&[data-parent-hovered="true"]': { + transition: 'opacity 0.4s ease', + opacity: 1, + }, + '&[data-parent-selected="true"]': { + transition: 'opacity 0.4s ease', + opacity: 1, + }, + }, +}); diff --git a/packages/libs/react-ui/src/components/Tabs/Tabs.stories.tsx b/packages/libs/react-ui/src/components/Tabs/Tabs.stories.tsx index 9bfc65fbbd..e437decdeb 100644 --- a/packages/libs/react-ui/src/components/Tabs/Tabs.stories.tsx +++ b/packages/libs/react-ui/src/components/Tabs/Tabs.stories.tsx @@ -1,18 +1,14 @@ import type { Meta, StoryObj } from '@storybook/react'; import React, { useState } from 'react'; import type { Key } from 'react-aria'; +import type { Node } from 'react-stately'; import { onLayer2 } from '../../storyDecorators'; import { Stack } from '../Layout'; import type { ITabsProps } from '../Tabs'; import { TabItem, Tabs } from '../Tabs'; import { Text } from '../Typography'; -interface IExampleTab { - title: string; - content: string; -} - -const ExampleTabs: IExampleTab[] = [ +const ExampleTabs = [ { title: 'Title 1', content: @@ -29,7 +25,8 @@ const ExampleTabs: IExampleTab[] = [ "There are many variations of passages of Lorem Ipsum available, but the majority have suffered alteration in some form, by injected humour, or randomised words which don't look even slightly believable. If you are going to use a passage of Lorem Ipsum, you need to be sure there isn't anything embarrassing hidden in the middle of text. All the Lorem Ipsum generators on the Internet tend to repeat predefined chunks as necessary, making this the first true generator on the Internet. It uses a dictionary of over 200 Latin words, combined with a handful of model sentence structures, to generate Lorem Ipsum which looks reasonable. The generated Lorem Ipsum is therefore always free from repetition, injected humour, or non-characteristic words etc. ", }, ]; -const ExampleManyTabs: IExampleTab[] = [ + +const ExampleManyTabs = [ { title: 'Really Long Title', content: 'Content for tab 1' }, { title: 'Really Long Title 2', content: 'Content for tab 2' }, { title: 'Really Long Title 3', content: 'Content for tab 3' }, @@ -44,7 +41,7 @@ const meta: Meta = { component: Tabs, decorators: [onLayer2], parameters: { - status: { type: 'releaseCandidate' }, + status: { type: 'beta' }, docs: { description: { component: @@ -91,6 +88,18 @@ const meta: Meta = { description: 'Handler that is called when the selection changes.', action: 'clicked', }, + borderPosition: { + description: 'Position of the border, top or bottom.', + control: { + type: 'radio', + }, + options: ['top', 'bottom'], + }, + inverse: { + control: { + type: 'boolean', + }, + }, }, }; @@ -103,9 +112,15 @@ export const TabsStory: Story = { ['aria-label']: 'generic tabs story', }, render: (props) => { + const [items, setItems] = useState(ExampleTabs); + + const handleClose = (item: Node) => { + setItems((prev) => prev.filter((i) => i.title !== item.key)); + }; + return ( - - {ExampleTabs.map((tab) => ( + + {items.map((tab) => ( {tab.content} @@ -119,7 +134,7 @@ export const DefaultSelectedTabsStory: Story = { name: 'Scrollable Tabs with defaultSelectedTab', args: { ['aria-label']: 'generic tabs story', - defaultSelectedKey: ExampleManyTabs[2].title, + defaultSelectedKey: ExampleManyTabs[5].title, }, render: (props) => { return ( diff --git a/packages/libs/react-ui/src/components/Tabs/Tabs.tsx b/packages/libs/react-ui/src/components/Tabs/Tabs.tsx index 690d3f5b0f..7bd41a0d2c 100644 --- a/packages/libs/react-ui/src/components/Tabs/Tabs.tsx +++ b/packages/libs/react-ui/src/components/Tabs/Tabs.tsx @@ -1,30 +1,41 @@ -import cn from 'classnames'; -import type { ReactNode } from 'react'; +'use client'; +import classNames from 'classnames'; +import type { ComponentProps, ReactNode } from 'react'; import React, { useEffect, useRef } from 'react'; import type { AriaTabListProps } from 'react-aria'; import { mergeProps, useFocusRing, useTabList } from 'react-aria'; +import type { Node } from 'react-stately'; import { Item as TabItem, useTabListState } from 'react-stately'; import { Tab } from './Tab'; import { TabPanel } from './TabPanel'; -import { - selectorLine, - tabListClass, - tabListWrapperClass, - tabsContainerClass, -} from './Tabs.css'; +import { scrollContainer, tabListClass, tabsContainerClass } from './Tabs.css'; +import { TabsPagination } from './TabsPagination'; export { TabItem }; -export type ITabItemProps = React.ComponentProps; +export type ITabItemProps = ComponentProps; export interface ITabsProps extends Omit, 'orientation' | 'items'> { className?: string; + inverse?: boolean; + borderPosition?: 'top' | 'bottom'; + onClose?: (item: Node) => void; + isCompact?: boolean; } -export const Tabs = ({ className, ...props }: ITabsProps): ReactNode => { +export const Tabs = ({ + className, + borderPosition = 'bottom', + inverse = false, + onClose, + isCompact, + ...props +}: ITabsProps): ReactNode => { const state = useTabListState(props); const containerRef = useRef(null); + const scrollRef = useRef(null); + const { focusProps, isFocusVisible } = useFocusRing({ within: true, }); @@ -35,48 +46,56 @@ export const Tabs = ({ className, ...props }: ITabsProps): ReactNode => { containerRef, ); - const selectedUnderlineRef = useRef(null); - + // set Selected as first tab if the tab isn't visible useEffect(() => { - if (!containerRef.current || !selectedUnderlineRef.current) { - return; - } - - let selected = containerRef.current.querySelector( + let selected = containerRef.current?.querySelector( '[data-selected="true"]', - ) as HTMLElement; + ) as HTMLElement | undefined; if (selected === undefined || selected === null) { - selected = containerRef.current.querySelectorAll( + selected = containerRef.current?.querySelectorAll( 'div[role="tab"]', )[0] as HTMLElement; } - selectedUnderlineRef.current.style.setProperty( - 'transform', - `translateX(${selected.offsetLeft}px)`, - ); - selectedUnderlineRef.current.style.setProperty( - 'width', - `${selected.offsetWidth}px`, - ); - }, [containerRef, state.selectedItem?.key, selectedUnderlineRef]); + if ( + selected && + scrollRef.current && + selected.offsetLeft + selected.offsetWidth > + (containerRef.current?.offsetWidth || 0) + ) { + scrollRef.current.scrollLeft = selected.offsetLeft; + } + }, []); return ( -
-
-
- {[...state.collection].map((item) => ( - - ))} - +
+ +
+
+ {[...state.collection].map((item) => ( + + ))} +
-
- +
); diff --git a/packages/libs/react-ui/src/components/Tabs/TabsPagination.tsx b/packages/libs/react-ui/src/components/Tabs/TabsPagination.tsx new file mode 100644 index 0000000000..c4e150bacf --- /dev/null +++ b/packages/libs/react-ui/src/components/Tabs/TabsPagination.tsx @@ -0,0 +1,115 @@ +'use client'; +import { + MonoArrowBackIosNew, + MonoArrowForwardIos, +} from '@kadena/react-icons/system'; +import classNames from 'classnames'; +import type { ReactElement, RefObject } from 'react'; +import React, { useEffect, useState } from 'react'; +import { debounce } from '../../utils'; +import { Button } from '../Button'; +import { hiddenClass, paginationButton, tabListControls } from './Tabs.css'; +import { calculateScroll } from './utils/calculateScroll'; + +interface ITabsPaginationProps { + children: ReactElement; + wrapperContainerRef: RefObject; + scrollContainerRef: RefObject; +} + +export const TabsPagination = ({ + children, + wrapperContainerRef, + scrollContainerRef, +}: ITabsPaginationProps) => { + const [visibleButtons, setVisibleButtons] = useState({ + left: false, + right: false, + }); + + const determineButtonVisibility = () => { + if (!scrollContainerRef.current || !wrapperContainerRef.current) return; + + const viewWidth = wrapperContainerRef.current.offsetWidth; + const maxWidth = wrapperContainerRef.current.scrollWidth; + const scrollPosition = scrollContainerRef.current.scrollLeft; + + if (scrollPosition === 0) { + scrollContainerRef.current.classList.remove('paginationLeft'); + + setVisibleButtons((prev) => ({ ...prev, left: false })); + } else { + scrollContainerRef.current.classList.add('paginationLeft'); + + setVisibleButtons((prev) => ({ ...prev, left: true })); + } + // 20 is a margin to prevent having a very small last bit to scroll + if (viewWidth + scrollPosition >= maxWidth - 20) { + scrollContainerRef.current.classList.remove('paginationRight'); + + setVisibleButtons((prev) => ({ ...prev, right: false })); + } else { + scrollContainerRef.current.classList.add('paginationRight'); + + setVisibleButtons((prev) => ({ ...prev, right: true })); + } + }; + + const handlePagination = (direction: 'back' | 'forward') => { + if (!wrapperContainerRef.current || !scrollContainerRef.current) return; + + const nextValue = calculateScroll( + direction, + wrapperContainerRef, + scrollContainerRef, + ); + + scrollContainerRef.current.scrollLeft = nextValue; + }; + + useEffect(() => { + // Initial check + determineButtonVisibility(); + + window.addEventListener('resize', () => determineButtonVisibility()); + scrollContainerRef.current?.addEventListener( + 'scroll', + debounce(() => determineButtonVisibility(), 100), + ); + + return () => { + window.removeEventListener('resize', () => determineButtonVisibility()); + scrollContainerRef.current?.removeEventListener('scroll', () => + determineButtonVisibility(), + ); + }; + }, []); + + return ( +
+ + {children} + +
+ ); +}; diff --git a/packages/libs/react-ui/src/components/Tabs/utils/calculateScroll.test.tsx b/packages/libs/react-ui/src/components/Tabs/utils/calculateScroll.test.tsx new file mode 100644 index 0000000000..427c6f4a78 --- /dev/null +++ b/packages/libs/react-ui/src/components/Tabs/utils/calculateScroll.test.tsx @@ -0,0 +1,55 @@ +import type { RefObject } from 'react'; +import { describe, expect, it } from 'vitest'; +import { calculateScroll } from './calculateScroll'; +import { mockChildElementsRef } from './getMinimalChildWidth.test'; + +const wrapperContainerRef = { + current: { + ...mockChildElementsRef.current, + scrollWidth: 1000, + offsetWidth: 100, + }, +} as RefObject; + +const currentValue = (scrollLeft: number) => + ({ + current: { + scrollLeft, + }, + }) as RefObject; + +describe('calculateScroll', () => { + it('should return 0 if direction is back and currentValue is less than offset', () => { + expect(calculateScroll('back', wrapperContainerRef, currentValue(0))).toBe( + 0, + ); + }); + + it('should return 700 if direction is back and currentValue is 750', () => { + expect( + calculateScroll('back', wrapperContainerRef, currentValue(750)), + ).toBe(700); + }); + + it('should return 50 if direction is forward and currentValue is 0', () => { + expect( + calculateScroll('forward', wrapperContainerRef, currentValue(0)), + ).toBe(50); + }); + + it('Should scroll to the end when the next value is more than the max width', () => { + expect( + calculateScroll('forward', wrapperContainerRef, currentValue(951)), + ).toBe(900); + }); + + it("Should return 0 if elements aren't present", () => { + expect( + calculateScroll( + 'forward', + {} as unknown as RefObject, + {} as unknown as RefObject, + ), + ).toBe(0); + }); +}); diff --git a/packages/libs/react-ui/src/components/Tabs/utils/calculateScroll.tsx b/packages/libs/react-ui/src/components/Tabs/utils/calculateScroll.tsx new file mode 100644 index 0000000000..8730967bda --- /dev/null +++ b/packages/libs/react-ui/src/components/Tabs/utils/calculateScroll.tsx @@ -0,0 +1,30 @@ +import type { RefObject } from 'react'; +import { getMinimalChildWidth } from './getMinimalChildWidth'; + +export const calculateScroll = ( + direction: 'back' | 'forward', + wrapperContainerRef: RefObject, + scrollContainerRef: RefObject, +) => { + if (!wrapperContainerRef.current || !scrollContainerRef.current) return 0; + const maxWidth = wrapperContainerRef.current.scrollWidth; + const viewWidth = wrapperContainerRef.current.offsetWidth; + const offset = getMinimalChildWidth(wrapperContainerRef); + const currentValue = scrollContainerRef.current.scrollLeft; + + if (direction === 'forward') { + const nextValue = currentValue + offset; + + if (nextValue > maxWidth) { + return maxWidth - viewWidth; + } + + return nextValue; + } else { + if (Math.abs(currentValue) < offset) { + return 0; + } + + return currentValue - offset; + } +}; diff --git a/packages/libs/react-ui/src/components/Tabs/utils/getMinimalChildWidth.test.tsx b/packages/libs/react-ui/src/components/Tabs/utils/getMinimalChildWidth.test.tsx new file mode 100644 index 0000000000..6b5e8c2caf --- /dev/null +++ b/packages/libs/react-ui/src/components/Tabs/utils/getMinimalChildWidth.test.tsx @@ -0,0 +1,37 @@ +import type { RefObject } from 'react'; +import { describe, expect, it } from 'vitest'; +import { getMinimalChildWidth } from './getMinimalChildWidth'; + +export const mockChildElementsRef = { + current: { + children: [ + { + offsetWidth: 59, + }, + { + offsetWidth: 50, + }, + { + offsetWidth: 55, + }, + { + offsetWidth: 60, + }, + ], + length: 3, + }, +} as unknown as RefObject; + +describe('getMinimalChildWidth', () => { + it('should return the lowest value', async () => { + expect(getMinimalChildWidth(mockChildElementsRef)).toBe(50); + }); + + it('should return 0 if no children are present', async () => { + expect( + getMinimalChildWidth({ + current: { length: 0 }, + } as unknown as RefObject), + ).toBe(0); + }); +}); diff --git a/packages/libs/react-ui/src/components/Tabs/utils/getMinimalChildWidth.tsx b/packages/libs/react-ui/src/components/Tabs/utils/getMinimalChildWidth.tsx new file mode 100644 index 0000000000..57bdf86ba8 --- /dev/null +++ b/packages/libs/react-ui/src/components/Tabs/utils/getMinimalChildWidth.tsx @@ -0,0 +1,19 @@ +import type { RefObject } from 'react'; + +export const getMinimalChildWidth = (ref: RefObject) => { + const children = ref.current?.children; + + if (!children) { + return 0; + } + + const widths = Array.from(children) + .map((child) => (child as HTMLElement)?.offsetWidth) + .filter(Boolean); + + if (widths.length === 0) { + return 0; + } + + return Math.min(...widths); +}; diff --git a/packages/libs/react-ui/src/utils/debounce.test.ts b/packages/libs/react-ui/src/utils/debounce.test.ts new file mode 100644 index 0000000000..3bde2e778f --- /dev/null +++ b/packages/libs/react-ui/src/utils/debounce.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from 'vitest'; +import { debounce } from './debounce'; + +vi.useFakeTimers(); + +describe('debounce', () => { + it('should call the function only once after the wait time', () => { + const mockFunction = vi.fn(); + const debouncedFunction = debounce(mockFunction, 1000); + + debouncedFunction(); + debouncedFunction(); + debouncedFunction(); + + expect(mockFunction).not.toBeCalled(); + + vi.runAllTimers(); + + expect(mockFunction).toBeCalledTimes(1); + }); +}); diff --git a/packages/libs/react-ui/src/utils/debounce.ts b/packages/libs/react-ui/src/utils/debounce.ts new file mode 100644 index 0000000000..642ff6c0a6 --- /dev/null +++ b/packages/libs/react-ui/src/utils/debounce.ts @@ -0,0 +1,12 @@ +export function debounce(func: (...args: any[]) => void, wait: number) { + let timeout: NodeJS.Timeout; + + return function executedFunction(...args: any[]) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} diff --git a/packages/libs/react-ui/src/utils/index.ts b/packages/libs/react-ui/src/utils/index.ts index 5b4135fc34..41deb1fd88 100644 --- a/packages/libs/react-ui/src/utils/index.ts +++ b/packages/libs/react-ui/src/utils/index.ts @@ -1,5 +1,6 @@ export * from './aria'; export * from './array'; +export { debounce } from './debounce'; export * from './is'; export * from './object'; export * from './testId';