diff --git a/.changeset/lazy-avocados-mate.md b/.changeset/lazy-avocados-mate.md new file mode 100644 index 0000000000..b24b1efe80 --- /dev/null +++ b/.changeset/lazy-avocados-mate.md @@ -0,0 +1,5 @@ +--- +'@alfalab/core-components-tabs': minor +--- + +Добавлены контролы в scrollable контейнер десктопных табов diff --git a/packages/tabs/package.json b/packages/tabs/package.json index bc4f2f6af3..ebf1c6628a 100644 --- a/packages/tabs/package.json +++ b/packages/tabs/package.json @@ -45,12 +45,15 @@ "@alfalab/core-components-keyboard-focusable": "^4.1.0", "@alfalab/core-components-tag": "^6.0.1", "@alfalab/core-components-picker-button": "^11.1.1", + "@alfalab/core-components-icon-button": "^6.2.3", "@alfalab/core-components-badge": "^5.2.0", "@alfalab/core-components-mq": "^4.2.0", - "@alfalab/hooks": "^1.13.0", "@alfalab/core-components-shared": "^0.3.0", + "@alfalab/hooks": "^1.13.0", + "@alfalab/icons-glyph": "^2.108.0", "classnames": "^2.3.1", "compute-scroll-into-view": "^1.0.20", + "lodash.debounce": "^4.0.8", "@juggle/resize-observer": "^3.3.1", "tslib": "^2.4.0" }, diff --git a/packages/tabs/src/collapsible.ts b/packages/tabs/src/collapsible.ts index 152726c702..d3754b29c7 100644 --- a/packages/tabs/src/collapsible.ts +++ b/packages/tabs/src/collapsible.ts @@ -1,10 +1,2 @@ -export { - TabsCollapsibleResponsive as TabsCollapsible, - TabsCollapsibleResponsiveProps as TabsCollapsibleProps, -} from './components/tabs/Component.collapsible.responsive'; -export * from './components/tabs/Component.collabsible.desktop'; -export * from './components/tabs/Component.collapsible.mobile'; -export * from './components/primary-tablist/Component.collapsible.responsive'; -export * from './components/primary-tablist/Component.collapsible.desktop'; -export * from './components/primary-tablist/Component.collapsible.mobile'; +export { TabsCollapsible, TabsCollapsibleProps } from './components/tabs/Component.collapsible'; export * from './components/tab'; diff --git a/packages/tabs/src/components/primary-tablist/Component.collapsible.desktop.tsx b/packages/tabs/src/components/primary-tablist/Component.collapsible.desktop.tsx deleted file mode 100644 index 0ddcad60a7..0000000000 --- a/packages/tabs/src/components/primary-tablist/Component.collapsible.desktop.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; - -import { TabListProps } from '../../typings'; - -import { CollapsiblePrimaryTabList } from './Component.collapsible'; - -import styles from './index.module.css'; - -export const CollapsiblePrimaryTabListDesktop = ({ size = 'm', ...restProps }: TabListProps) => ( - -); diff --git a/packages/tabs/src/components/primary-tablist/Component.collapsible.mobile.tsx b/packages/tabs/src/components/primary-tablist/Component.collapsible.mobile.tsx deleted file mode 100644 index a70e62bfe1..0000000000 --- a/packages/tabs/src/components/primary-tablist/Component.collapsible.mobile.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import cn from 'classnames'; - -import { TabListProps } from '../../typings'; - -import { CollapsiblePrimaryTabList } from './Component.collapsible'; - -import commonStyles from './index.module.css'; -import mobileStyles from './mobile.module.css'; - -const styles = { - ...commonStyles, - ...mobileStyles, -}; - -export type CollapsiblePrimaryTabListMobileProps = Omit; - -export const CollapsiblePrimaryTabListMobile = ({ - className, - ...restProps -}: CollapsiblePrimaryTabListMobileProps) => ( - -); diff --git a/packages/tabs/src/components/primary-tablist/Component.collapsible.responsive.tsx b/packages/tabs/src/components/primary-tablist/Component.collapsible.responsive.tsx deleted file mode 100644 index 5df29c82fd..0000000000 --- a/packages/tabs/src/components/primary-tablist/Component.collapsible.responsive.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; - -import { useMatchMedia } from '@alfalab/core-components-mq'; - -import { TabListProps } from '../../typings'; - -import { CollapsiblePrimaryTabListDesktop } from './Component.collapsible.desktop'; -import { CollapsiblePrimaryTabListMobile } from './Component.collapsible.mobile'; - -export const CollapsiblePrimaryTabListResponsive = ({ - size, - fullWidthScroll, - breakpoint = 1024, - defaultMatchMediaValue, - ...restProps -}: TabListProps) => { - const [isDesktop] = useMatchMedia(`(min-width: ${breakpoint}px)`, defaultMatchMediaValue); - - return isDesktop ? ( - - ) : ( - - ); -}; diff --git a/packages/tabs/src/components/primary-tablist/Component.collapsible.tsx b/packages/tabs/src/components/primary-tablist/Component.collapsible.tsx index 55fe5cbd6e..cb2620a6d8 100644 --- a/packages/tabs/src/components/primary-tablist/Component.collapsible.tsx +++ b/packages/tabs/src/components/primary-tablist/Component.collapsible.tsx @@ -10,17 +10,16 @@ import { import { useTablistTitles } from '../../hooks/use-tablist-titles'; import { createSyntheticMouseEvent } from '../../synthetic-events'; -import { Styles, TabListProps } from '../../typings'; +import { TabListProps } from '../../typings'; import { Title } from '../title'; -const DEFAULT_STYLES = {}; +import styles from './index.module.css'; export const CollapsiblePrimaryTabList = ({ - size, + size = 'm', className, containerClassName, titles = [], - styles = DEFAULT_STYLES, selectedId = titles.length ? titles[0].id : undefined, collapsedTabsIds, fullWidthScroll, @@ -28,7 +27,7 @@ export const CollapsiblePrimaryTabList = ({ dataTestId, breakpoint = 1024, defaultMatchMediaValue, -}: TabListProps & Styles) => { +}: TabListProps) => { const lineRef = useRef(null); const { containerRef, addonRef, tablistTitles, selectedTab, getTabListItemProps } = @@ -61,7 +60,7 @@ export const CollapsiblePrimaryTabList = ({ return options; }, []), - [tablistTitles, styles], + [tablistTitles], ); const collapsedAddonsLength = tablistTitles.filter( @@ -112,7 +111,7 @@ export const CollapsiblePrimaryTabList = ({ ) : null } - size='l' + size='m' view='ghost' label='Ещё' popoverPosition='bottom-end' diff --git a/packages/tabs/src/components/primary-tablist/Component.desktop.tsx b/packages/tabs/src/components/primary-tablist/Component.desktop.tsx index f53f3c8bda..a884b16916 100644 --- a/packages/tabs/src/components/primary-tablist/Component.desktop.tsx +++ b/packages/tabs/src/components/primary-tablist/Component.desktop.tsx @@ -7,5 +7,5 @@ import { PrimaryTabList } from './Component'; import styles from './index.module.css'; export const PrimaryTabListDesktop = ({ size = 'm', ...restProps }: TabListProps) => ( - + ); diff --git a/packages/tabs/src/components/primary-tablist/Component.mobile.tsx b/packages/tabs/src/components/primary-tablist/Component.mobile.tsx index 776b194f6d..5ad260c02d 100644 --- a/packages/tabs/src/components/primary-tablist/Component.mobile.tsx +++ b/packages/tabs/src/components/primary-tablist/Component.mobile.tsx @@ -16,5 +16,10 @@ const styles = { export type PrimaryTabListMobileProps = Omit; export const PrimaryTabListMobile = ({ className, ...restProps }: PrimaryTabListMobileProps) => ( - + ); diff --git a/packages/tabs/src/components/primary-tablist/Component.tsx b/packages/tabs/src/components/primary-tablist/Component.tsx index c7d119125f..68d89c58ec 100644 --- a/packages/tabs/src/components/primary-tablist/Component.tsx +++ b/packages/tabs/src/components/primary-tablist/Component.tsx @@ -4,7 +4,7 @@ import cn from 'classnames'; import { KeyboardFocusable } from '@alfalab/core-components-keyboard-focusable'; import { useTabs } from '../../hooks/use-tabs'; -import { Styles, TabListProps } from '../../typings'; +import { PlatformProps, Styles, TabListProps } from '../../typings'; import { ScrollableContainer } from '../scrollable-container'; import { Title } from '../title'; @@ -19,7 +19,8 @@ export const PrimaryTabList = ({ fullWidthScroll, onChange, dataTestId, -}: TabListProps & Styles) => { + platform, +}: TabListProps & Styles & PlatformProps) => { const lineRef = useRef(null); const { selectedTab, focusedTab, getTabListItemProps } = useTabs({ @@ -65,6 +66,9 @@ export const PrimaryTabList = ({ activeChild={focusedTab || selectedTab} containerClassName={containerClassName} fullWidthScroll={fullWidthScroll} + view='primary' + size={size} + platform={platform} > {renderContent()} diff --git a/packages/tabs/src/components/primary-tablist/index.module.css b/packages/tabs/src/components/primary-tablist/index.module.css index 93d4bb000b..0610a1de77 100644 --- a/packages/tabs/src/components/primary-tablist/index.module.css +++ b/packages/tabs/src/components/primary-tablist/index.module.css @@ -81,6 +81,10 @@ transition: transform 0.2s ease, width 0.2s ease; } +.option { + color: var(--color-light-text-primary); +} + /* sizes */ .s, diff --git a/packages/tabs/src/components/scroll-controls/Component.tsx b/packages/tabs/src/components/scroll-controls/Component.tsx new file mode 100644 index 0000000000..236a05a0f4 --- /dev/null +++ b/packages/tabs/src/components/scroll-controls/Component.tsx @@ -0,0 +1,74 @@ +import React, { forwardRef, RefObject, useEffect, useState } from 'react'; +import cn from 'classnames'; +import _debounce from 'lodash.debounce'; + +import { IconButton } from '@alfalab/core-components-icon-button'; +import { ChevronLeftMIcon } from '@alfalab/icons-glyph/ChevronLeftMIcon'; +import { ChevronRightMIcon } from '@alfalab/icons-glyph/ChevronRightMIcon'; + +import { TabsProps } from '../../typings'; + +import { getDisabledState, scrollIntoFirstTab, scrollIntoLastTab } from './utils'; + +import styles from './index.module.css'; + +type ScrollControlsProps = { + view: Exclude; + size: TabsProps['size']; + containerRef: RefObject; +}; + +export const ScrollControls = forwardRef( + ({ containerRef, view, size: sizeProp }, ref) => { + const container = containerRef.current; + const [disabledState, updateDisabledState] = useState(() => getDisabledState(container)); + + useEffect(() => { + const handleScroll = _debounce(() => { + updateDisabledState(getDisabledState(container)); + }, 150); + + container?.addEventListener('scroll', handleScroll); + + return () => container?.removeEventListener('scroll', handleScroll); + }, [container]); + + const getSize = () => { + if (view === 'primary') { + return sizeProp === 'xl' ? 'xs' : 'xxs'; + } + + return sizeProp && ['s', 'm', 'l', 'xl'].includes(sizeProp) ? 's' : 'xs'; + }; + + const handleScrollLeft = () => scrollIntoFirstTab(container); + + const handleScrollRight = () => scrollIntoLastTab(container); + + const commonButtonProps = { + className: styles.button, + size: getSize(), + view: 'secondary', + } as const; + + return ( +
+ + +
+ ); + }, +); diff --git a/packages/tabs/src/components/scroll-controls/index.module.css b/packages/tabs/src/components/scroll-controls/index.module.css new file mode 100644 index 0000000000..a7061cac38 --- /dev/null +++ b/packages/tabs/src/components/scroll-controls/index.module.css @@ -0,0 +1,45 @@ +@import '../../../../themes/src/default.css'; +@import '../../vars.css'; + +.component { + display: flex; + flex-shrink: 0; +} + +.primary { + position: relative; + width: 72px; + align-items: flex-start; + justify-content: flex-end; + + &:before { + content: ''; + display: block; + position: absolute; + bottom: 1px; + height: 1px; + width: 100%; + background-color: var(--primary-tablist-bottom-border-color); + } + + .button:first-child { + margin-right: var(--gap-xs); + } + + &.xl .button:first-child { + margin-right: var(--gap-2xs); + } +} + +.secondary { + align-items: center; + justify-content: center; + + &.xs { + width: 76px; + + .button:first-child { + margin-right: var(--gap-2xs); + } + } +} diff --git a/packages/tabs/src/components/scroll-controls/index.ts b/packages/tabs/src/components/scroll-controls/index.ts new file mode 100644 index 0000000000..6d084e2060 --- /dev/null +++ b/packages/tabs/src/components/scroll-controls/index.ts @@ -0,0 +1 @@ +export { ScrollControls } from './Component'; diff --git a/packages/tabs/src/components/scroll-controls/utils.ts b/packages/tabs/src/components/scroll-controls/utils.ts new file mode 100644 index 0000000000..fc8c14cbfe --- /dev/null +++ b/packages/tabs/src/components/scroll-controls/utils.ts @@ -0,0 +1,60 @@ +const ADDITIONAL_OFFSET = 15; + +function getTabs(container: HTMLDivElement) { + return Array.from(container.querySelectorAll('button[role="tab"]')) as HTMLButtonElement[]; +} + +function findLastVisibleTab(container: HTMLDivElement) { + const tabs = getTabs(container); + + return tabs.reduce((res, tab) => { + if (tab.offsetLeft + ADDITIONAL_OFFSET < container.clientWidth + container.scrollLeft) { + return tab; + } + + return res; + }, tabs[0]); +} + +function findFirstVisibleTab(container: HTMLDivElement) { + const tabs = getTabs(container); + + return tabs.reduceRight((res, tab) => { + if (tab.offsetLeft + tab.clientWidth > container.scrollLeft + ADDITIONAL_OFFSET) { + return tab; + } + + return res; + }, tabs[tabs.length - 1]); +} + +export function scrollIntoLastTab(container: HTMLDivElement | null) { + if (!container) return; + + findLastVisibleTab(container).scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'start', + }); +} + +export function scrollIntoFirstTab(container: HTMLDivElement | null) { + if (!container) return; + + findFirstVisibleTab(container).scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'end', + }); +} + +export function getDisabledState(container: HTMLDivElement | null) { + if (!container) return { toLeft: false, toRight: false }; + const scrollOffset = 2; + + const toLeft = container.scrollLeft <= scrollOffset; + const toRight = + container.scrollLeft + container.clientWidth >= container.scrollWidth - scrollOffset; + + return { toLeft, toRight }; +} diff --git a/packages/tabs/src/components/scrollable-container/Component.tsx b/packages/tabs/src/components/scrollable-container/Component.tsx index d2214f7af8..9987ee0f83 100644 --- a/packages/tabs/src/components/scrollable-container/Component.tsx +++ b/packages/tabs/src/components/scrollable-container/Component.tsx @@ -1,17 +1,23 @@ -import React, { ReactNode, useEffect } from 'react'; +import React, { ReactNode, useEffect, useRef, useState } from 'react'; import cn from 'classnames'; import computeScrollIntoView from 'compute-scroll-into-view'; -import { TabsProps } from '../../typings'; +import { PlatformProps, TabsProps } from '../../typings'; +import { ScrollControls } from '../scroll-controls'; import styles from './index.module.css'; /** * Дополнительная прокрутка при клике на не поместившийся таб */ -const ADDITIONAL_SCROLLLEFT_VALUE = 40; +const ADDITIONAL_SCROLL_LEFT_VALUE = 50; export type ScrollableContainerProps = { + /** + * Дополнительный класс враппера контейнера + */ + containerWrapperClassName?: string; + /** * Дополнительный класс контейнера */ @@ -26,20 +32,48 @@ export type ScrollableContainerProps = { * Активный элемент (всегда будет в видимой области) */ activeChild: HTMLElement | null; + + /** + * Внешний вид заголовков табов + */ + view: Exclude; + + /** + * Размер + */ + size: TabsProps['size']; +}; + +const isOverflown = ( + { clientWidth, scrollWidth }: HTMLDivElement, + controlsNode: HTMLDivElement | null, +) => { + const controlsWidth = controlsNode?.offsetWidth || 0; + + return scrollWidth > clientWidth + controlsWidth; }; export const ScrollableContainer = ({ + containerWrapperClassName, containerClassName, children, activeChild, fullWidthScroll, -}: ScrollableContainerProps & Pick) => { + view, + size, + platform, +}: ScrollableContainerProps & Pick & PlatformProps) => { + const containerRef = useRef(null); + const controlsRef = useRef(null); + const [overflown, setOverflown] = useState(false); + useEffect(() => { if (activeChild) { const actions = computeScrollIntoView(activeChild, { scrollMode: 'if-needed', block: 'nearest', inline: 'nearest', + boundary: (parent) => !parent.isSameNode(containerRef.current), }); // TODO: animate? @@ -47,19 +81,54 @@ export const ScrollableContainer = ({ // eslint-disable-next-line no-param-reassign el.scrollLeft = el.scrollLeft > left - ? left - ADDITIONAL_SCROLLLEFT_VALUE - : left + ADDITIONAL_SCROLLLEFT_VALUE; + ? left - ADDITIONAL_SCROLL_LEFT_VALUE + : left + ADDITIONAL_SCROLL_LEFT_VALUE; }); } }, [activeChild]); + useEffect(() => { + const scrollableNode = containerRef.current; + const tabsContainer = scrollableNode?.firstElementChild; + + if (platform === 'desktop' && scrollableNode && tabsContainer && window.ResizeObserver) { + const observerCb = () => { + if (isOverflown(scrollableNode, controlsRef.current)) { + setOverflown(true); + } else { + setOverflown(false); + } + }; + + const observer = new ResizeObserver(observerCb); + + observer.observe(scrollableNode); + observer.observe(tabsContainer); + + return () => observer.disconnect(); + } + + return () => {}; + }, [platform]); + return ( -
- {children} +
+
+ {children} +
+ {overflown && platform === 'desktop' ? ( + + ) : null}
); }; diff --git a/packages/tabs/src/components/scrollable-container/index.module.css b/packages/tabs/src/components/scrollable-container/index.module.css index 8bdde00a55..1842e1d19e 100644 --- a/packages/tabs/src/components/scrollable-container/index.module.css +++ b/packages/tabs/src/components/scrollable-container/index.module.css @@ -1,12 +1,17 @@ @import '../../../../themes/src/default.css'; @import '../../vars.css'; +.scrollableContainerWrapper { + display: flex; +} + .container { position: relative; overflow-x: auto; overflow-y: hidden; scroll-behavior: smooth; scrollbar-width: none; + flex: 1; /* focus-outline fix */ margin: var(--gap-2xs-neg) 0 var(--gap-2xs-neg) var(--gap-2xs-neg); @@ -22,5 +27,6 @@ } .fullWidthScroll { - margin: 0 var(--gap-m-neg); + padding-left: 0; + margin: var(--gap-2xs-neg) var(--gap-m-neg); } diff --git a/packages/tabs/src/components/secondary-tablist/Component.desktop.tsx b/packages/tabs/src/components/secondary-tablist/Component.desktop.tsx index 21abb7759d..de77815019 100644 --- a/packages/tabs/src/components/secondary-tablist/Component.desktop.tsx +++ b/packages/tabs/src/components/secondary-tablist/Component.desktop.tsx @@ -20,5 +20,6 @@ export const SecondaryTabListDesktop = ({ size={size} styles={commonStyles} tagSize={size} + platform='desktop' /> ); diff --git a/packages/tabs/src/components/secondary-tablist/Component.mobile.tsx b/packages/tabs/src/components/secondary-tablist/Component.mobile.tsx index 3463f60bf0..6298026be0 100644 --- a/packages/tabs/src/components/secondary-tablist/Component.mobile.tsx +++ b/packages/tabs/src/components/secondary-tablist/Component.mobile.tsx @@ -15,10 +15,11 @@ const styles = { ...mobileStyles, }; -export type SecondaryTabListMobileProps = Omit; +export type SecondaryTabListMobileProps = Omit; export const SecondaryTabListMobile = ({ className, + size, ...restProps }: SecondaryTabListMobileProps) => ( ); diff --git a/packages/tabs/src/components/secondary-tablist/Component.responsive.tsx b/packages/tabs/src/components/secondary-tablist/Component.responsive.tsx index bfb4237995..43c45e6559 100644 --- a/packages/tabs/src/components/secondary-tablist/Component.responsive.tsx +++ b/packages/tabs/src/components/secondary-tablist/Component.responsive.tsx @@ -8,7 +8,6 @@ import { SecondaryTabListDesktop } from './Component.desktop'; import { SecondaryTabListMobile } from './Component.mobile'; export const SecondaryTabListResponsive = ({ - size, defaultMatchMediaValue, fullWidthScroll, breakpoint = 1024, @@ -17,7 +16,7 @@ export const SecondaryTabListResponsive = ({ const [isDesktop] = useMatchMedia(`(min-width: ${breakpoint}px)`, defaultMatchMediaValue); return isDesktop ? ( - + ) : ( ); diff --git a/packages/tabs/src/components/secondary-tablist/Component.tsx b/packages/tabs/src/components/secondary-tablist/Component.tsx index 4578e9f502..d0d8595465 100644 --- a/packages/tabs/src/components/secondary-tablist/Component.tsx +++ b/packages/tabs/src/components/secondary-tablist/Component.tsx @@ -2,7 +2,7 @@ import React from 'react'; import cn from 'classnames'; import { useTabs } from '../../hooks/use-tabs'; -import { SecondaryTabListProps, Styles } from '../../typings'; +import { PlatformProps, SecondaryTabListProps, Styles } from '../../typings'; import { ScrollableContainer } from '../scrollable-container'; export const SecondaryTabList = ({ @@ -18,7 +18,10 @@ export const SecondaryTabList = ({ onChange, dataTestId, TagComponent, -}: SecondaryTabListProps & Styles) => { + platform, + tagShape, + tagView, +}: SecondaryTabListProps & Styles & PlatformProps) => { const { focusedTab, selectedTab, getTabListItemProps } = useTabs({ titles, selectedId, @@ -42,6 +45,8 @@ export const SecondaryTabList = ({ return ( {renderContent()} diff --git a/packages/tabs/src/components/tabs/Component.collabsible.desktop.tsx b/packages/tabs/src/components/tabs/Component.collabsible.desktop.tsx deleted file mode 100644 index cde4a6f23f..0000000000 --- a/packages/tabs/src/components/tabs/Component.collabsible.desktop.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; - -import { TabsProps } from '../../typings'; -import { CollapsiblePrimaryTabListDesktop } from '../primary-tablist/Component.collapsible.desktop'; - -import { Tabs } from './Component'; - -export type TabsCollapsibleDesktopProps = Omit< - TabsProps, - 'TabList' | 'fullWidthScroll' | 'scrollable' | 'view' ->; - -export const TabsCollapsibleDesktop = ({ ...restProps }: TabsCollapsibleDesktopProps) => ( - -); diff --git a/packages/tabs/src/components/tabs/Component.collapsible.mobile.tsx b/packages/tabs/src/components/tabs/Component.collapsible.mobile.tsx deleted file mode 100644 index 36a0835b32..0000000000 --- a/packages/tabs/src/components/tabs/Component.collapsible.mobile.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; - -import { TabsProps } from '../../typings'; -import { CollapsiblePrimaryTabListMobile } from '../primary-tablist/Component.collapsible.mobile'; - -import { Tabs } from './Component'; - -export type TabsCollapsibleMobileProps = Omit< - TabsProps, - 'TabList' | 'scrollable' | 'view' | 'size' ->; - -export const TabsCollapsibleMobile = ({ ...restProps }: TabsCollapsibleMobileProps) => ( - -); diff --git a/packages/tabs/src/components/tabs/Component.collapsible.responsive.tsx b/packages/tabs/src/components/tabs/Component.collapsible.responsive.tsx deleted file mode 100644 index 05847cc705..0000000000 --- a/packages/tabs/src/components/tabs/Component.collapsible.responsive.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; - -import { TabsProps } from '../../typings'; -import { CollapsiblePrimaryTabListResponsive } from '../primary-tablist/Component.collapsible.responsive'; - -import { Tabs } from './Component'; - -export type TabsCollapsibleResponsiveProps = Omit; - -export const TabsCollapsibleResponsive = ({ ...restProps }: TabsCollapsibleResponsiveProps) => ( - -); diff --git a/packages/tabs/src/components/tabs/Component.collapsible.tsx b/packages/tabs/src/components/tabs/Component.collapsible.tsx new file mode 100644 index 0000000000..d49c88010e --- /dev/null +++ b/packages/tabs/src/components/tabs/Component.collapsible.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +import { TabsProps } from '../../typings'; +import { CollapsiblePrimaryTabList } from '../primary-tablist/Component.collapsible'; + +import { Tabs } from './Component'; + +export type TabsCollapsibleProps = Omit< + TabsProps, + 'TabList' | 'fullWidthScroll' | 'scrollable' | 'view' +>; + +export const TabsCollapsible = ({ ...restProps }: TabsCollapsibleProps) => ( + +); diff --git a/packages/tabs/src/components/tabs/Component.mobile.tsx b/packages/tabs/src/components/tabs/Component.mobile.tsx index be56c58ab9..759e075344 100644 --- a/packages/tabs/src/components/tabs/Component.mobile.tsx +++ b/packages/tabs/src/components/tabs/Component.mobile.tsx @@ -11,7 +11,7 @@ const views = { secondary: SecondaryTabListMobile, }; -export type TabsMobileProps = Omit; +export type TabsMobileProps = Omit; export const TabsMobile = ({ view = 'primary', diff --git a/packages/tabs/src/components/tabs/Component.tsx b/packages/tabs/src/components/tabs/Component.tsx index 3da40f442f..d82872035e 100644 --- a/packages/tabs/src/components/tabs/Component.tsx +++ b/packages/tabs/src/components/tabs/Component.tsx @@ -17,6 +17,8 @@ export const Tabs = ({ dataTestId, onChange, breakpoint = 1024, + tagShape, + tagView, }: Omit) => { const tabsArray = React.Children.toArray(children) as TabsProps['children']; const titles = tabsArray.map( @@ -59,6 +61,8 @@ export const Tabs = ({ defaultMatchMediaValue={defaultMatchMediaValue} fullWidthScroll={fullWidthScroll} breakpoint={breakpoint} + tagShape={tagShape} + tagView={tagView} /> {tabs.map((tab) => cloneElement(tab, { hidden: tab.props.id !== selectedId }))} diff --git a/packages/tabs/src/components/tabs/__snapshots__/Component.test.tsx.snap b/packages/tabs/src/components/tabs/__snapshots__/Component.test.tsx.snap index ff5c180c41..ad81b95c8b 100644 --- a/packages/tabs/src/components/tabs/__snapshots__/Component.test.tsx.snap +++ b/packages/tabs/src/components/tabs/__snapshots__/Component.test.tsx.snap @@ -189,88 +189,92 @@ exports[`Tabs Snapshots tests should match snapshot 3`] = `
- - + - + - + - + -
+ + Таб 5 + + +
+
- - - - - + + Таб 1 + + + addon + + + + + + +
{ - tabs = [ - { title: 'Aurum', id: 'tab-1' }, - { title: 'Bercelium', id: 'tab-2' }, - { title: 'Curium', id: 'tab-3' }, - { title: 'Neptunium', id: 'tab-4' }, - { title: 'Plutonuim', id: 'tab-5' }, - { title: 'Rubidium', id: 'tab-6' }, - { title: 'Californium', id: 'tab-7' }, - ]; + const [selectedId, setSelectedId] = React.useState(TABS[0].id); - const [selectedId, setSelectedId] = React.useState('tab-1'); - const [title, setTitle] = React.useState('Aurum'); + const [count, setCount] = React.useState('3'); + const [overflowType, setOverflowType] = React.useState('scrollable'); const handleChange = (event, { selectedId }) => { - const currentTab = tabs.filter((tab) => tab.id === selectedId)[0]; setSelectedId(selectedId); - setTitle(currentTab.title); }; + const TabsComponent = overflowType === 'collapsible' ? TabsCollapsible : TabsDesktop; + return ( <> - - - - - - - - - + + {TABS.slice(0, Number(count)).map((item) => ( + + ))} +
-
Выбран таб: {title}
+ + Выбран таб: {TABS.find((tab) => tab.id === selectedId).title} + + + + + + { + setCount(e.target.value); + setSelectedId(TABS[0].id); + }} + > + + + + + setOverflowType(e.target.value)} + > + + + + ); }); //MOBILE +const TABS = [ + { title: 'Aurum', id: 'tab-1' }, + { title: 'Bercelium', id: 'tab-2' }, + { title: 'Curium', id: 'tab-3' }, + { title: 'Neptunium', id: 'tab-4' }, + { title: 'Plutonuim', id: 'tab-5' }, + { title: 'Rubidium', id: 'tab-6' }, + { title: 'Californium', id: 'tab-7' }, + { title: 'Hydrogenium', id: 'tab-8' }, + { title: 'Helium', id: 'tab-9' }, + { title: 'Lithium', id: 'tab-10' }, + { title: 'Beryllium', id: 'tab-11' }, + { title: 'Borum', id: 'tab-12' }, + { title: 'Carboneum', id: 'tab-13' }, + { title: 'Nitrogenium', id: 'tab-14' }, + { title: 'Oxygenium', id: 'tab-15' }, +]; + render(() => { - tabs = [ - { title: 'Aurum', id: 'tab-1' }, - { title: 'Bercelium', id: 'tab-2' }, - { title: 'Curium', id: 'tab-3' }, - { title: 'Neptunium', id: 'tab-4' }, - { title: 'Plutonuim', id: 'tab-5' }, - { title: 'Rubidium', id: 'tab-6' }, - { title: 'Californium', id: 'tab-7' }, - ]; + const [selectedId, setSelectedId] = React.useState(TABS[0].id); - const [selectedId, setSelectedId] = React.useState('tab-1'); - const [title, setTitle] = React.useState('Aurum'); + const [count, setCount] = React.useState('3'); const handleChange = (event, { selectedId }) => { - const currentTab = tabs.filter((tab) => tab.id === selectedId)[0]; setSelectedId(selectedId); - setTitle(currentTab.title); }; return ( <> - - - - - - - - - -
-
Выбран таб: {title}
- - ); -}); -``` - -## TabsSecondary - -Для переключения контента внутри блока рекомендуется использовать второстепенный вид табов. -Для десктопа рекомендуется использовать S и XS размер табов и XXS размер для мобильной версии интерфейса. - -```jsx live -render(() => { - tabs = [ - { title: 'Aurum', id: 'tab-1' }, - { title: 'Bercelium', id: 'tab-2' }, - { title: 'Curium', id: 'tab-3' }, - { title: 'Neptunium', id: 'tab-4' }, - { title: 'Plutonuim', id: 'tab-5' }, - { title: 'Rubidium', id: 'tab-6' }, - { title: 'Californium', id: 'tab-7' }, - ]; - - const [selectSecondaryId, setSelectedSecondaryId] = React.useState('tab-1'); - const [title, setTitle] = React.useState('Aurum'); - - const handleSecondaryChange = (event, { selectedId }) => { - const currentTab = tabs.filter((tab) => tab.id === selectedId)[0]; - setSelectedSecondaryId(selectedId); - setTitle(currentTab.title); - }; - - return ( -
- - - - - - - - - -
-
Выбран таб: {title}
-
- ); -}); -//MOBILE -render(() => { - tabs = [ - { title: 'Aurum', id: 'tab-1' }, - { title: 'Bercelium', id: 'tab-2' }, - { title: 'Curium', id: 'tab-3' }, - { title: 'Neptunium', id: 'tab-4' }, - { title: 'Plutonuim', id: 'tab-5' }, - { title: 'Rubidium', id: 'tab-6' }, - { title: 'Californium', id: 'tab-7' }, - ]; - - const [selectSecondaryId, setSelectedSecondaryId] = React.useState('tab-1'); - const [title, setTitle] = React.useState('Aurum'); - - const handleSecondaryChange = (event, { selectedId }) => { - const currentTab = tabs.filter((tab) => tab.id === selectedId)[0]; - setSelectedSecondaryId(selectedId); - setTitle(currentTab.title); - }; - - return ( -
- - - - - - - + {TABS.slice(0, Number(count)).map((item) => ( + + ))}
-
Выбран таб: {title}
-
+ + Выбран таб: {TABS.find((tab) => tab.id === selectedId).title} + + + + + + { + setCount(e.target.value); + setSelectedId(TABS[0].id); + }} + > + + + + + ); }); ``` -## Переполнение - -Если табов больше, чем может отобразиться на экране, можно воспользоваться одной из двух опций, горизонтальный скролл или скрытие табов в кнопку `Eщё`. -Для второстепенных табов опция Collapsible недоступна. +При необходимости часть табов можно скрыть в таб Ещё. Данная механика доступна только на десктопных устройствах. -```jsx live -const tabs = [ +```jsx live desktopOnly +const TABS = [ { title: 'Aurum', id: 'tab-1' }, { title: 'Bercelium', id: 'tab-2' }, { title: 'Curium', id: 'tab-3' }, @@ -183,181 +159,186 @@ const tabs = [ { title: 'Plutonuim', id: 'tab-5' }, { title: 'Rubidium', id: 'tab-6' }, { title: 'Californium', id: 'tab-7' }, - { title: 'Einsteinium', id: 'tab-8', rightAddons: }, - { title: 'Fermium', id: 'tab-9' }, - { title: 'Mendelevium', id: 'tab-10' }, + { title: 'Hydrogenium', id: 'tab-8' }, + { title: 'Helium', id: 'tab-9' }, + { title: 'Lithium', id: 'tab-10' }, + { title: 'Beryllium', id: 'tab-11' }, + { title: 'Borum', id: 'tab-12' }, + { title: 'Carboneum', id: 'tab-13' }, + { title: 'Nitrogenium', id: 'tab-14' }, + { title: 'Oxygenium', id: 'tab-15' }, ]; + render(() => { - const [selectedId, setSelectedId] = React.useState('tab-1'); - const [title, setTitle] = React.useState('Aurum'); - const [view, setView] = React.useState('primary'); - const [overflow, setOverflow] = React.useState('scrollable'); - const [collapsibleTabsIds, setCollapsibleTabsIds] = React.useState({ 'tab-1': true }); + const [selectedId, setSelectedId] = React.useState(TABS[0].id); + const [collapsibleTabsIds, setCollapsibleTabsIds] = React.useState([]); const handleChange = (event, { selectedId }) => { - const currentTab = tabs.filter((tab) => tab.id === selectedId)[0]; setSelectedId(selectedId); - setTitle(currentTab.title); }; - const handleViewChange = (event, { value }) => setView(value); - const handleOverflowChange = (event, { value }) => setOverflow(value); - const handleCollapsedTabsChange = (event, payload) => { - setCollapsibleTabsIds({ ...collapsibleTabsIds, [payload.name]: payload.checked }); + const handleCollapsibleChange = (e, { name, checked }) => { + if (checked) { + setCollapsibleTabsIds((p) => [...p, name]); + } else { + setCollapsibleTabsIds((p) => p.filter((id) => id !== name)); + } }; - const manuallyCollapsedTabs = React.useMemo(() => { - const v = Object.keys(collapsibleTabsIds).filter((k) => !!collapsibleTabsIds[k]); - return v.length ? v : undefined; - }, [collapsibleTabsIds]); - return ( -
- {overflow === 'scrollable' ? ( - - {tabs.map((tab) => ( - - ))} - - ) : ( - - {tabs.map((tab) => ( - - ))} - - )} -
-
Выбран таб: {title}
-
+ <> + + {TABS.map((item) => ( + + ))} +
-
- - - - - - - - - - {overflow === 'collapsible' && ( - - {tabs.map((t) => ( - - ))} - - )} - -
-
+ + Выбран таб: {TABS.find((tab) => tab.id === selectedId).title} + + + + + + {TABS.slice(4, 7).map((t) => ( + + ))} + + ); }); -//MOBILE -const tabs = [ +``` + +## TabsSecondary + +Для переключения контента внутри блока рекомендуется использовать второстепенный вид табов. +Для десктопа рекомендуется использовать S и XS размер табов и XXS размер для мобильной версии интерфейса. + +```jsx live +const TABS = [ { title: 'Aurum', id: 'tab-1' }, { title: 'Bercelium', id: 'tab-2' }, - { title: 'Curium', id: 'tab-3', rightAddons: }, + { title: 'Curium', id: 'tab-3' }, { title: 'Neptunium', id: 'tab-4' }, { title: 'Plutonuim', id: 'tab-5' }, { title: 'Rubidium', id: 'tab-6' }, { title: 'Californium', id: 'tab-7' }, - { title: 'Einsteinium', id: 'tab-8' }, - { title: 'Fermium', id: 'tab-9' }, - { title: 'Mendelevium', id: 'tab-10' }, + { title: 'Hydrogenium', id: 'tab-8' }, + { title: 'Helium', id: 'tab-9' }, + { title: 'Lithium', id: 'tab-10' }, + { title: 'Beryllium', id: 'tab-11' }, + { title: 'Borum', id: 'tab-12' }, + { title: 'Carboneum', id: 'tab-13' }, + { title: 'Nitrogenium', id: 'tab-14' }, + { title: 'Oxygenium', id: 'tab-15' }, ]; + +const IS_MOBILE = document.body.clientWidth < 450; + render(() => { - const [selectedId, setSelectedId] = React.useState('tab-1'); - const [title, setTitle] = React.useState('Aurum'); - const [view, setView] = React.useState('primary'); - const [overflow, setOverflow] = React.useState('scrollable'); + const [selectedId, setSelectedId] = React.useState(TABS[0].id); + + const [count, setCount] = React.useState('3'); + const [size, setSize] = React.useState(IS_MOBILE ? 'xs' : 's'); + const [shape, setShape] = React.useState('rounded'); + const [tagView, setTagView] = React.useState('outlined'); const handleChange = (event, { selectedId }) => { - const currentTab = tabs.filter((tab) => tab.id === selectedId)[0]; setSelectedId(selectedId); - setTitle(currentTab.title); }; - const handleViewChange = (event, { value }) => setView(value); - const handleOverflowChange = (event, { value }) => setOverflow(value); return ( -
- + - {tabs.map((tab) => ( - + {TABS.slice(0, Number(count)).map((item) => ( + ))} - -
-
Выбран таб: {title}
+
-
-
- - - - - - - - - -
-
+ + Выбран таб: {TABS.find((tab) => tab.id === selectedId).title} + + + + + + { + setCount(e.target.value); + setSelectedId(TABS[0].id); + }} + > + + + + + setSize(e.target.value)} + > + {!IS_MOBILE && } + + + + + setShape(e.target.value)} + > + + + + + setTagView(e.target.value)} + > + + + + + ); }); ``` ## Анатомия -С помощью слота `RightAddons` можно кастомизировать тэг. Например, добавить иконку. +С помощью слота `RightAddons` можно кастомизировать таб. Например, добавить иконку. ```jsx live render(() => { @@ -453,8 +434,10 @@ render(() => { ## Состояния -Таб может находиться в активном и неактивном состоянии. Взаимодействие может быть ограничено с помощью свойства `disabled`. -Дисейблить активный таб недопустимо. +Таб может находиться в активном и неактивном состоянии. + +Взаимодействие может быть ограничено с помощью свойства `disabled`. +Допускается ограничивать взаимодействие только с неактивными табами. ```jsx live render(() => { diff --git a/packages/tabs/src/typings.ts b/packages/tabs/src/typings.ts index 9dc3a45517..bfb32e9a68 100644 --- a/packages/tabs/src/typings.ts +++ b/packages/tabs/src/typings.ts @@ -85,6 +85,16 @@ export type TabsProps = { * @default 1024 */ breakpoint?: number; + + /** + * Форма тега (для view secondary только) + */ + tagShape?: TagProps['shape']; + + /** + * Стиль тега (для view secondary только) + */ + tagView?: TagProps['view']; }; export type TabProps = { @@ -163,6 +173,8 @@ export type TabListProps = Pick< | 'onChange' | 'dataTestId' | 'fullWidthScroll' + | 'tagShape' + | 'tagView' > & { /** * Заголовки табов @@ -185,3 +197,7 @@ export type UseTabsProps = TabListProps; export type Styles = { styles?: { [key: string]: string }; }; + +export type PlatformProps = { + platform: 'desktop' | 'mobile'; +};