From f6bd88c01c8a566fe96ec1df06ff06269784a6b8 Mon Sep 17 00:00:00 2001 From: Doug MacKenzie Date: Fri, 13 Dec 2024 09:35:17 +1100 Subject: [PATCH] Tabs (future): add carousel functionality when tabs overflow container (#5355) * Tabs (future): add carousel functionality when tabs overflow container * Replace Button with div onClick :O * Add a couple more tabs to story * Stylelint * Add stickersheets * Limit tab selector to unique container * Add unique ids for stickersheet examples * Support for RTL * Adjust tab hover back to gray * Add SB/Chromatic test for carousel functionality * Add cleanup to disconnect observers * Add root element to observers to avoid arrows showing when cut off vertically * Adjust SB test to assert arrows are in the document * When selected tab changes, scroll it into view * Use new spacing var * Add typeof Tabs to spec stories meta * Add prefix to testids and only render when testid passed in props * Move scrollAmount to a constant * Extract isRTL into a util * Add a show/hide arrow SB test for RTL --- .changeset/thin-actors-knock.md | 5 + .../Tabs/_docs/Tabs.spec.stories.tsx | 118 ++++++++++++++ .../Tabs/_docs/Tabs.stickersheet.stories.tsx | 84 ++++++++++ .../__future__/Tabs/_docs/Tabs.stories.tsx | 13 +- .../src/__future__/Tabs/constants.ts | 1 + .../__future__/Tabs/subcomponents/Tab/Tab.tsx | 2 +- .../subcomponents/TabList/TabList.module.css | 54 ++++++- .../Tabs/subcomponents/TabList/TabList.tsx | 148 ++++++++++++++++-- .../src/__utilities__/isRTL/index.ts | 1 + .../src/__utilities__/isRTL/isRTL.spec.tsx | 38 +++++ .../src/__utilities__/isRTL/isRTL.ts | 6 + 11 files changed, 457 insertions(+), 13 deletions(-) create mode 100644 .changeset/thin-actors-knock.md create mode 100644 packages/components/src/__future__/Tabs/_docs/Tabs.spec.stories.tsx create mode 100644 packages/components/src/__future__/Tabs/_docs/Tabs.stickersheet.stories.tsx create mode 100644 packages/components/src/__future__/Tabs/constants.ts create mode 100644 packages/components/src/__utilities__/isRTL/index.ts create mode 100644 packages/components/src/__utilities__/isRTL/isRTL.spec.tsx create mode 100644 packages/components/src/__utilities__/isRTL/isRTL.ts diff --git a/.changeset/thin-actors-knock.md b/.changeset/thin-actors-knock.md new file mode 100644 index 00000000000..37f56f49323 --- /dev/null +++ b/.changeset/thin-actors-knock.md @@ -0,0 +1,5 @@ +--- +'@kaizen/components': patch +--- + +Tabs (future): add carousel functionality when tabs overflow container width diff --git a/packages/components/src/__future__/Tabs/_docs/Tabs.spec.stories.tsx b/packages/components/src/__future__/Tabs/_docs/Tabs.spec.stories.tsx new file mode 100644 index 00000000000..ffc0b92b01e --- /dev/null +++ b/packages/components/src/__future__/Tabs/_docs/Tabs.spec.stories.tsx @@ -0,0 +1,118 @@ +import React from 'react' +import { Meta, StoryObj } from '@storybook/react' +import { within, userEvent, expect } from '@storybook/test' +import { Text } from '~components/Text' +import { Tab, TabList, TabPanel, Tabs } from '../index' + +const meta = { + title: 'Components/Tabs/Tabs (Future)/Tests', + parameters: { + controls: { disable: true }, + }, + args: { + children: ( + <> + + Tab 1 + Tab 2 + + Tab 3 + + + Disabled Tab + + Tab 4 + Tab 5 + + + Content 1 + + + Content 2 + + + Content 3 + + + Disabled content + + + Content 4 + + + Content 5 + + + ), + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const ArrowsShowingAndHiding: Story = { + render: (args) => { + return ( +
+ +
+ ) + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement.parentElement!) + + expect(canvas.queryByTestId('kz-tablist-left-arrow')).not.toBeInTheDocument() + + const rightArrow = await canvas.findByTestId('sb-arrows-kz-tablist-right-arrow') + + await userEvent.click(rightArrow) + await new Promise((r) => setTimeout(r, 500)) + + const leftArrow = await canvas.findByTestId('sb-arrows-kz-tablist-left-arrow') + + expect(leftArrow).toBeInTheDocument() + expect(rightArrow).toBeInTheDocument() + + await userEvent.click(rightArrow) + await new Promise((r) => setTimeout(r, 500)) + + expect(leftArrow).toBeInTheDocument() + expect(rightArrow).not.toBeInTheDocument() + }, +} + +export const ArrowsShowingAndHidingRTL: Story = { + name: 'Arrows Showing and Hiding (RTL)', + parameters: { + textDirection: 'rtl', + }, + render: (args) => { + return ( +
+ +
+ ) + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement.parentElement!) + + expect(canvas.queryByTestId('kz-tablist-right-arrow')).not.toBeInTheDocument() + + const leftArrow = await canvas.findByTestId('sb-arrows-kz-tablist-left-arrow') + + await userEvent.click(leftArrow) + await new Promise((r) => setTimeout(r, 500)) + + const rightArrow = await canvas.findByTestId('sb-arrows-kz-tablist-right-arrow') + + expect(leftArrow).toBeInTheDocument() + expect(rightArrow).toBeInTheDocument() + + await userEvent.click(leftArrow) + await new Promise((r) => setTimeout(r, 500)) + + expect(rightArrow).toBeInTheDocument() + expect(leftArrow).not.toBeInTheDocument() + }, +} diff --git a/packages/components/src/__future__/Tabs/_docs/Tabs.stickersheet.stories.tsx b/packages/components/src/__future__/Tabs/_docs/Tabs.stickersheet.stories.tsx new file mode 100644 index 00000000000..69e61e52982 --- /dev/null +++ b/packages/components/src/__future__/Tabs/_docs/Tabs.stickersheet.stories.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import { Meta } from '@storybook/react' +import { Text } from '~components/Text' +import { StickerSheet, StickerSheetStory } from '~storybook/components/StickerSheet' +import { Tab, TabList, TabPanel, Tabs } from '../index' + +export default { + title: 'Components/Tabs/Tabs (Future)', + parameters: { + chromatic: { disable: false }, + controls: { disable: true }, + }, +} satisfies Meta + +const ExampleTabs = ({ id }: { id: string }): JSX.Element => ( + + + Tab 1 + Tab 2 + + Tab 3 + + + Disabled Tab + + Tab 4 + Tab 5 + + + Content 1 + + + Content 2 + + + Content 3 + + + Disabled content + + + Content 4 + + + Content 5 + + +) + +const StickerSheetTemplate: StickerSheetStory = { + render: () => { + return ( + <> + + + + + + + + +
+ +
+
+
+ + ) + }, +} + +export const StickerSheetDefault: StickerSheetStory = { + ...StickerSheetTemplate, + name: 'Sticker Sheet (Default)', +} + +export const StickerSheetRTL: StickerSheetStory = { + ...StickerSheetTemplate, + name: 'Sticker Sheet (RTL)', + parameters: { + ...StickerSheetTemplate.parameters, + textDirection: 'rtl', + }, +} diff --git a/packages/components/src/__future__/Tabs/_docs/Tabs.stories.tsx b/packages/components/src/__future__/Tabs/_docs/Tabs.stories.tsx index eac7e7ce897..c107857375a 100644 --- a/packages/components/src/__future__/Tabs/_docs/Tabs.stories.tsx +++ b/packages/components/src/__future__/Tabs/_docs/Tabs.stories.tsx @@ -16,9 +16,11 @@ const meta = { Tab 3 - + Disabled Tab + Tab 4 + Tab 5 Content 1 @@ -29,6 +31,15 @@ const meta = { Content 3 + + Content 4 + + + Content 4 + + + Content 5 + ), }, diff --git a/packages/components/src/__future__/Tabs/constants.ts b/packages/components/src/__future__/Tabs/constants.ts new file mode 100644 index 00000000000..3cc6d5a704d --- /dev/null +++ b/packages/components/src/__future__/Tabs/constants.ts @@ -0,0 +1 @@ +export const SCROLL_AMOUNT = 120 diff --git a/packages/components/src/__future__/Tabs/subcomponents/Tab/Tab.tsx b/packages/components/src/__future__/Tabs/subcomponents/Tab/Tab.tsx index e522ccf2cd9..4a16e6b7b3d 100644 --- a/packages/components/src/__future__/Tabs/subcomponents/Tab/Tab.tsx +++ b/packages/components/src/__future__/Tabs/subcomponents/Tab/Tab.tsx @@ -28,7 +28,7 @@ export const Tab = (props: TabProps): JSX.Element => { } return ( - + {({ isSelected, isFocusVisible, isHovered }) => ( <> {children} diff --git a/packages/components/src/__future__/Tabs/subcomponents/TabList/TabList.module.css b/packages/components/src/__future__/Tabs/subcomponents/TabList/TabList.module.css index dc9cad7ca7e..c0fda872113 100644 --- a/packages/components/src/__future__/Tabs/subcomponents/TabList/TabList.module.css +++ b/packages/components/src/__future__/Tabs/subcomponents/TabList/TabList.module.css @@ -1,8 +1,60 @@ +.container { + position: relative; +} + .tabList { border-bottom: 1px solid rgba(var(--color-gray-600-rgb), 0.1); - padding: var(--spacing-xs) var(--spacing-md) 0; + padding: var(--spacing-6) 0 0; + width: 100%; + height: 100%; + overflow-x: scroll; + white-space: nowrap; + scrollbar-width: none; + scroll-behavior: smooth; } .noPadding { padding: 0; } + +.leftArrow, +.rightArrow { + --icon-size: 24; + + display: flex; + align-items: center; + justify-content: center; + position: absolute; + z-index: 10000; + background: var(--color-white); + inset-block: 0 1px; + width: 48px; + cursor: default; + user-select: none; +} + +/* + * Note: we're purposefully using directional properties instead of start/end for positioning and styling related to the carousel arrows +*/ +.leftArrow { + left: 0; +} + +.leftArrow, +.leftArrow:hover { + border-right: 1px solid rgba(var(--color-gray-600-rgb), 0.1); +} + +.rightArrow { + right: 0; +} + +.rightArrow, +.rightArrow:hover { + border-left: 1px solid rgba(var(--color-gray-600-rgb), 0.1); +} + +.leftArrow:hover, +.rightArrow:hover { + background: var(--color-gray-200); +} diff --git a/packages/components/src/__future__/Tabs/subcomponents/TabList/TabList.tsx b/packages/components/src/__future__/Tabs/subcomponents/TabList/TabList.tsx index 274a08b752b..af272a7e8e6 100644 --- a/packages/components/src/__future__/Tabs/subcomponents/TabList/TabList.tsx +++ b/packages/components/src/__future__/Tabs/subcomponents/TabList/TabList.tsx @@ -1,6 +1,13 @@ -import React, { ReactNode } from 'react' +import React, { ReactNode, useContext, useEffect, useId, useRef, useState } from 'react' import classnames from 'classnames' -import { TabList as RACTabList, TabListProps as RACTabListProps } from 'react-aria-components' +import { + TabList as RACTabList, + TabListProps as RACTabListProps, + TabListStateContext, +} from 'react-aria-components' +import { Icon } from '~components/__future__/Icon' +import { isRTL as isRTLCheck } from '~components/__utilities__/isRTL' +import { SCROLL_AMOUNT } from '../../constants' import styles from './TabList.module.css' export type TabListProps = { @@ -13,20 +20,141 @@ export type TabListProps = { */ 'noPadding'?: boolean 'children': ReactNode + 'data-testid'?: string } & RACTabListProps /** * Wrapper for the tabs themselves */ export const TabList = (props: TabListProps): JSX.Element => { - const { 'aria-label': ariaLabel, noPadding = false, children, className, ...restProps } = props + const { + 'aria-label': ariaLabel, + noPadding = false, + children, + className, + 'data-testid': testId, + ...restProps + } = props + const [isDocumentReady, setIsDocumentReady] = useState(false) + const [leftArrowEnabled, setLeftArrowEnabled] = useState(false) + const [rightArrowEnabled, setRightArrowEnabled] = useState(false) + const tabListRef = useRef(null) + const tabListId = useId() + const [isRTL, setIsRTL] = useState(false) + const [containerElement, setContainerElement] = useState() + const tabListContext = useContext(TabListStateContext) + const selectedKey = tabListContext?.selectedKey + + useEffect(() => { + if (!isDocumentReady) { + setIsDocumentReady(true) + return + } + + const container = document.getElementById(tabListId) + setContainerElement(container) + setIsRTL(container ? isRTLCheck(container) : false) + }, [isDocumentReady, tabListId]) + + useEffect(() => { + if (!isDocumentReady) { + return + } + + const tabs = containerElement?.querySelectorAll('[data-kz-tab]') + if (!tabs) { + return + } + + const firstTabObserver = new IntersectionObserver( + (entries) => { + if (!entries[0].isIntersecting) { + setLeftArrowEnabled(true) + return + } + setLeftArrowEnabled(false) + }, + { + threshold: 0.75, + root: containerElement, + }, + ) + firstTabObserver.observe(isRTL ? tabs[tabs.length - 1] : tabs[0]) + + const lastTabObserver = new IntersectionObserver( + (entries) => { + if (!entries[0].isIntersecting) { + setRightArrowEnabled(true) + return + } + setRightArrowEnabled(false) + }, + { + threshold: 0.75, + root: containerElement, + }, + ) + lastTabObserver.observe(isRTL ? tabs[0] : tabs[tabs.length - 1]) + + return () => { + firstTabObserver.disconnect() + lastTabObserver.disconnect() + } + }, [isDocumentReady, containerElement, isRTL]) + + useEffect(() => { + if (!isDocumentReady) { + return + } + + // Scroll selected tab into view + containerElement + ?.querySelector('[role="tab"][data-selected=true]') + ?.scrollIntoView({ block: 'nearest', inline: 'center' }) + }, [selectedKey, containerElement, isDocumentReady]) + + const handleArrowPress = (direction: 'left' | 'right'): void => { + if (tabListRef.current) { + const tabListScrollPos = tabListRef.current.scrollLeft + const newSpot = + direction === 'left' ? tabListScrollPos - SCROLL_AMOUNT : tabListScrollPos + SCROLL_AMOUNT + tabListRef.current.scrollLeft = newSpot + } + } + return ( - - {children} - +
+ {leftArrowEnabled && ( + // making a conscious decision to use
over
) } diff --git a/packages/components/src/__utilities__/isRTL/index.ts b/packages/components/src/__utilities__/isRTL/index.ts new file mode 100644 index 00000000000..692938f1210 --- /dev/null +++ b/packages/components/src/__utilities__/isRTL/index.ts @@ -0,0 +1 @@ +export * from './isRTL' diff --git a/packages/components/src/__utilities__/isRTL/isRTL.spec.tsx b/packages/components/src/__utilities__/isRTL/isRTL.spec.tsx new file mode 100644 index 00000000000..84dcc9fc086 --- /dev/null +++ b/packages/components/src/__utilities__/isRTL/isRTL.spec.tsx @@ -0,0 +1,38 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { isRTL } from './isRTL' + +describe('isRTL', () => { + it('returns false when no element with dir found', () => { + const Example = (): JSX.Element => + render() + const button = screen.getByRole('button') + expect(isRTL(button)).toBe(false) + }) + + it('returns false when greater parent is dir=rtl, but closer parent is dir=ltr', () => { + const Example = (): JSX.Element => ( +
+
+ +
+
+ ) + render() + const button = screen.getByRole('button') + expect(isRTL(button)).toBe(false) + }) + + it('returns true when greater parent is dir=ltr, but closer parent is dir=rtl', () => { + const Example = (): JSX.Element => ( +
+
+ +
+
+ ) + render() + const button = screen.getByRole('button') + expect(isRTL(button)).toBe(true) + }) +}) diff --git a/packages/components/src/__utilities__/isRTL/isRTL.ts b/packages/components/src/__utilities__/isRTL/isRTL.ts new file mode 100644 index 00000000000..47ac4ca8188 --- /dev/null +++ b/packages/components/src/__utilities__/isRTL/isRTL.ts @@ -0,0 +1,6 @@ +/** + * Finds the first ancestor with a `dir` property on it + * Returning true is that is `dir=rtl` and returning false in all other cases + */ +export const isRTL = (element: Element): boolean => + !!element.closest('[dir]')?.matches('[dir="rtl"]')