diff --git a/packages/tabs/package.json b/packages/tabs/package.json index 37f7c83f29..4ecd8e11d9 100644 --- a/packages/tabs/package.json +++ b/packages/tabs/package.json @@ -24,11 +24,11 @@ "dependencies": { "@leafygreen-ui/a11y": "^1.4.13", "@leafygreen-ui/box": "^3.1.9", + "@leafygreen-ui/descendants": "^0.1.1", "@leafygreen-ui/emotion": "^4.0.8", "@leafygreen-ui/hooks": "^8.1.3", "@leafygreen-ui/lib": "^13.3.0", "@leafygreen-ui/palette": "^4.0.9", - "@leafygreen-ui/portal": "^5.1.1", "@leafygreen-ui/tokens": "^2.7.0", "@leafygreen-ui/typography": "^19.1.1", "@lg-tools/test-harnesses": "0.1.2" @@ -45,10 +45,10 @@ "url": "https://jira.mongodb.org/projects/PD/summary" }, "devDependencies": { - "@leafygreen-ui/card": "^10.0.7", "@leafygreen-ui/button": "^21.2.0", + "@leafygreen-ui/card": "^10.0.7", + "@leafygreen-ui/icon": "^12.0.1", "@leafygreen-ui/icon-button": "^15.0.21", - "@leafygreen-ui/icon": "^12.2.0", "@lg-tools/storybook-utils": "^0.1.1" } } diff --git a/packages/tabs/src/Tab/InternalTab.tsx b/packages/tabs/src/Tab/InternalTab.tsx deleted file mode 100644 index bbebfd8825..0000000000 --- a/packages/tabs/src/Tab/InternalTab.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React, { useMemo } from 'react'; - -import { useIdAllocator } from '@leafygreen-ui/hooks'; -import Portal from '@leafygreen-ui/portal'; - -import TabTitle from '../TabTitle'; - -import { InternalTabProps } from './Tab.types'; - -const InternalTab = React.memo( - ({ child, selected, tabRef, panelRef, ...tabProps }: InternalTabProps) => { - const { id: idProp, name } = child.props; - - const panelId = useIdAllocator({ prefix: 'tab-panel' }); - const tabId = useIdAllocator({ prefix: 'tab', id: idProp }); - - const tab = ( - - {name} - - ); - - const panel = useMemo( - () => - React.cloneElement(child, { - id: panelId, - selected: selected, - ['aria-labelledby']: tabId, - }), - [child, panelId, selected, tabId], - ); - - return ( - <> - {tab} - {panel} - - ); - }, -); - -InternalTab.displayName = 'InternalTab'; - -export default InternalTab; diff --git a/packages/tabs/src/Tab/Tab.tsx b/packages/tabs/src/Tab/Tab.tsx index 3ccdc02fc3..5409e388c5 100644 --- a/packages/tabs/src/Tab/Tab.tsx +++ b/packages/tabs/src/Tab/Tab.tsx @@ -40,7 +40,6 @@ Tab.propTypes = { name: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), content: PropTypes.node, disabled: PropTypes.bool, - ariaControl: PropTypes.string, }; export default Tab; diff --git a/packages/tabs/src/Tab/Tab.types.ts b/packages/tabs/src/Tab/Tab.types.ts index 96514ce300..c5976d011e 100644 --- a/packages/tabs/src/Tab/Tab.types.ts +++ b/packages/tabs/src/Tab/Tab.types.ts @@ -1,20 +1,5 @@ import { HTMLElementProps } from '@leafygreen-ui/lib'; -import { TabsProps } from '../Tabs/Tabs.types'; - -export type InternalTabProps = Pick< - TabsProps, - 'as' | 'darkMode' | 'className' -> & { - child: React.ReactElement; - onKeyDown: (e: KeyboardEvent) => void; - onClick?: (e: React.MouseEvent) => void; - isAnyTabFocused?: boolean; - selected: boolean; - tabRef: HTMLDivElement | null; - panelRef: HTMLDivElement | null; -}; - export interface TabProps extends HTMLElementProps<'div'> { /** * Content that will appear as the title in the Tab list. @@ -57,12 +42,6 @@ export interface TabProps extends HTMLElementProps<'div'> { */ selected?: boolean; - /** - * TODO: remove, or do something with this - * @internal - */ - ariaControl?: string; - // Done in order to support any Router system, such that TabTitle component can accept any URL destination prop. [key: string]: any; } diff --git a/packages/tabs/src/Tab/index.ts b/packages/tabs/src/Tab/index.ts index 435d295af0..5f9fed405a 100644 --- a/packages/tabs/src/Tab/index.ts +++ b/packages/tabs/src/Tab/index.ts @@ -1,3 +1,2 @@ -export { default as InternalTab } from './InternalTab'; export { default } from './Tab'; export type { TabProps } from './Tab.types'; diff --git a/packages/tabs/src/TabPanel/TabPanel.tsx b/packages/tabs/src/TabPanel/TabPanel.tsx new file mode 100644 index 0000000000..af43582cae --- /dev/null +++ b/packages/tabs/src/TabPanel/TabPanel.tsx @@ -0,0 +1,33 @@ +import React, { useMemo } from 'react'; + +import { useDescendant } from '@leafygreen-ui/descendants'; + +import { + TabPanelDescendantsContext, + useTabDescendantsContext, +} from '../context'; + +import { TabPanelProps } from './TabPanel.types'; + +const TabPanel = ({ child, selectedIndex }: TabPanelProps) => { + const { id, index, ref } = useDescendant(TabPanelDescendantsContext); + const { tabDescendants } = useTabDescendantsContext(); + + const relatedTab = useMemo(() => { + return tabDescendants.find(tabDescendant => tabDescendant.index === index); + }, [tabDescendants, index]); + + return ( +
+ {React.cloneElement(child, { + id, + selected: !child.props.disabled && index === selectedIndex, + ['aria-labelledby']: relatedTab?.id, + })} +
+ ); +}; + +TabPanel.displayName = 'TabPanel'; + +export default TabPanel; diff --git a/packages/tabs/src/TabPanel/TabPanel.types.ts b/packages/tabs/src/TabPanel/TabPanel.types.ts new file mode 100644 index 0000000000..1ccbaaa0ae --- /dev/null +++ b/packages/tabs/src/TabPanel/TabPanel.types.ts @@ -0,0 +1,4 @@ +export interface TabPanelProps { + child: React.ReactElement; + selectedIndex: number; +} diff --git a/packages/tabs/src/TabPanel/index.ts b/packages/tabs/src/TabPanel/index.ts new file mode 100644 index 0000000000..637eaf3573 --- /dev/null +++ b/packages/tabs/src/TabPanel/index.ts @@ -0,0 +1 @@ +export { default } from './TabPanel'; diff --git a/packages/tabs/src/TabTitle/TabTitle.tsx b/packages/tabs/src/TabTitle/TabTitle.tsx index e54918acb7..66b5ecbb4b 100644 --- a/packages/tabs/src/TabTitle/TabTitle.tsx +++ b/packages/tabs/src/TabTitle/TabTitle.tsx @@ -1,11 +1,17 @@ -import React, { RefObject, useEffect, useRef } from 'react'; +import React, { RefObject, useCallback, useMemo, useRef } from 'react'; import Box, { ExtendableBox } from '@leafygreen-ui/box'; +import { useDescendant } from '@leafygreen-ui/descendants'; import { cx } from '@leafygreen-ui/emotion'; import { getNodeTextContent, Theme } from '@leafygreen-ui/lib'; import { BaseFontSize } from '@leafygreen-ui/tokens'; import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; +import { + TabDescendantsContext, + useTabPanelDescendantsContext, +} from '../context'; + import { listTitleChildrenStyles, listTitleFontSize, @@ -15,36 +21,34 @@ import { import { BaseTabTitleProps } from './TabTitle.types'; const TabTitle: ExtendableBox = ({ - selected = false, - disabled = false, children, className, darkMode, - parentRef, + disabled = false, + onClick, + selectedIndex, ...rest }: BaseTabTitleProps) => { - const titleRef = useRef(null); const baseFontSize: BaseFontSize = useUpdatedBaseFontSize(); + const titleRef = useRef(null); + const { index, ref, id } = useDescendant(TabDescendantsContext); + const { tabPanelDescendants } = useTabPanelDescendantsContext(); const theme = darkMode ? Theme.Dark : Theme.Light; + const selected = index === selectedIndex; - // Checks to see if the current activeElement is a part of the same tab set - // as the current TabTitle. If so, and the current TabTitle is not disabled - // and is selected, we manually move focus to that TabTitle. - useEffect(() => { - const tabsList = Array.from(parentRef?.children ?? []); - const activeEl = document.activeElement; + const relatedTabPanel = useMemo(() => { + return tabPanelDescendants.find( + tabPanelDescendant => tabPanelDescendant.index === index, + ); + }, [tabPanelDescendants, index]); - if ( - activeEl && - tabsList.indexOf(activeEl) !== -1 && - !disabled && - selected && - titleRef.current - ) { - titleRef.current.focus(); - } - }, [parentRef, disabled, selected, titleRef]); + const handleClick = useCallback( + (event: React.MouseEvent) => { + onClick(event, index); + }, + [index, onClick], + ); const nodeText = getNodeTextContent(rest.name); @@ -55,19 +59,22 @@ const TabTitle: ExtendableBox = ({ listTitleStyles, listTitleModeStyles[theme].base, { - [listTitleModeStyles[theme].selected]: selected, + [listTitleModeStyles[theme].selected]: !disabled && selected, [listTitleModeStyles[theme].hover]: !disabled && !selected, [listTitleModeStyles[theme].disabled]: disabled, }, listTitleModeStyles[theme].focus, className, ), + disabled, + id, + name: nodeText, + onClick: handleClick, role: 'tab', tabIndex: selected ? 0 : -1, - ['aria-selected']: selected, - name: nodeText, + ['aria-controls']: relatedTabPanel?.id, + ['aria-selected']: !disabled && selected, ['data-text']: nodeText, - disabled, } as const; if (typeof rest.href === 'string') { @@ -77,7 +84,9 @@ const TabTitle: ExtendableBox = ({ ref={titleRef as RefObject} {...sharedTabProps} > -
{children}
+
+ {children} +
); } @@ -88,7 +97,9 @@ const TabTitle: ExtendableBox = ({ ref={titleRef as RefObject} {...sharedTabProps} > -
{children}
+
+ {children} +
); }; diff --git a/packages/tabs/src/TabTitle/TabTitle.types.ts b/packages/tabs/src/TabTitle/TabTitle.types.ts index 8821b3defe..328da37b32 100644 --- a/packages/tabs/src/TabTitle/TabTitle.types.ts +++ b/packages/tabs/src/TabTitle/TabTitle.types.ts @@ -1,11 +1,10 @@ export interface BaseTabTitleProps { darkMode?: boolean; - selected?: boolean; href?: string; children?: React.ReactNode; className?: string; disabled?: boolean; isAnyTabFocused?: boolean; - parentRef?: HTMLDivElement; + selectedIndex: number; [key: string]: any; } diff --git a/packages/tabs/src/Tabs.spec.tsx b/packages/tabs/src/Tabs.spec.tsx index a6e4f16371..90a911a09c 100644 --- a/packages/tabs/src/Tabs.spec.tsx +++ b/packages/tabs/src/Tabs.spec.tsx @@ -77,16 +77,6 @@ describe('packages/tabs', () => { }); describe('when controlled', () => { - test('clicking a tab fires setSelected callback', () => { - const { getTabUtilsByName } = renderTabs({ setSelected, selected: 1 }); - const tabUtils = getTabUtilsByName('Second'); - - if (tabUtils) { - fireEvent.click(tabUtils.getTab()); - } - expect(setSelected).toHaveBeenCalled(); - }); - test(`renders "${tabsClassName}" to the tabs classList`, () => { renderTabs({ setSelected, @@ -134,8 +124,16 @@ describe('packages/tabs', () => { const selectedPanel = getSelectedPanel(); expect(selectedPanel).toHaveTextContent('Content 2'); }); + test('clicking a tab fires setSelected callback', () => { + const { getTabUtilsByName } = renderTabs({ setSelected, selected: 1 }); + const tabUtils = getTabUtilsByName('Second'); - test('clicking a tab does not change the selected tab panel', () => { + if (tabUtils) { + fireEvent.click(tabUtils.getTab()); + } + expect(setSelected).toHaveBeenCalled(); + }); + test('clicking a tab does not update selected index and calls setSelected callback', () => { const { getTabUtilsByName, getSelectedPanel } = renderTabs({ setSelected, selected: 1, @@ -148,9 +146,10 @@ describe('packages/tabs', () => { const selectedPanel = getSelectedPanel(); expect(selectedPanel).toHaveTextContent('Content 2'); + expect(setSelected).toHaveBeenCalled(); }); - test('keyboard nav is not supported', () => { + test('keying down arrow keys does not update selected index and calls setSelected callback', () => { const { getTabUtilsByName, getSelectedPanel } = renderTabs({ setSelected, selected: 1, @@ -165,6 +164,7 @@ describe('packages/tabs', () => { } expect(activeTab).toBeVisible(); + expect(setSelected).toHaveBeenCalled(); }); }); @@ -196,19 +196,21 @@ describe('packages/tabs', () => { test('keyboard navigation is supported', () => { const { getTabUtilsByName } = renderTabs({}, { default: true }); const firstTabUtils = getTabUtilsByName('First'); + const firstTab = firstTabUtils?.getTab(); const secondTabUtils = getTabUtilsByName('Second'); + const secondTab = secondTabUtils?.getTab(); // Focus on first tab userEvent.tab(); - expect(firstTabUtils?.getTab()).toHaveFocus(); + expect(firstTab).toHaveFocus(); // Keyboard navigate between tabs - if (firstTabUtils) { - fireEvent.keyDown(firstTabUtils.getTab(), { + if (firstTab) { + fireEvent.keyDown(firstTab, { key: keyMap.ArrowRight, }); } - expect(secondTabUtils?.getTab()).toHaveFocus(); + expect(secondTab).toHaveFocus(); }); test('keyboard navigation skips disabled tabs', () => { diff --git a/packages/tabs/src/Tabs.stories.tsx b/packages/tabs/src/Tabs.stories.tsx index 0bedce1f62..1ad08b7787 100644 --- a/packages/tabs/src/Tabs.stories.tsx +++ b/packages/tabs/src/Tabs.stories.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { storybookExcludedControlParams, StoryMetaType, @@ -28,6 +28,13 @@ const CardWithMargin = (props: any) => ( const Lipsum = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec ullamcorper nulla non metus auctor fringilla.`; +const defaultExcludedControls = [ + ...storybookExcludedControlParams, + 'children', + 'as', + 'setSelected', +]; + const meta: StoryMetaType = { title: 'Components/Tabs', component: Tabs, @@ -39,12 +46,7 @@ const meta: StoryMetaType = { }, }, controls: { - exclude: [ - ...storybookExcludedControlParams, - 'children', - 'as', - 'setSelected', - ], + exclude: defaultExcludedControls, }, }, args: { @@ -110,6 +112,24 @@ export const LiveExample: StoryFn = ({ ); +export const Controlled: StoryFn = (args: TabsProps) => { + const [selectedTab, setSelectedTab] = useState(0); + + return ( + + ); +}; +Controlled.parameters = { + chromatic: { disableSnapshot: true }, + controls: { + exclude: [...defaultExcludedControls, 'selected'], + }, +}; + export const WithInlineChildren = LiveExample.bind({}); WithInlineChildren.args = { inlineChildren: ( diff --git a/packages/tabs/src/Tabs/Tabs.styles.ts b/packages/tabs/src/Tabs/Tabs.styles.ts index 624d156087..3f4dedc7f0 100644 --- a/packages/tabs/src/Tabs/Tabs.styles.ts +++ b/packages/tabs/src/Tabs/Tabs.styles.ts @@ -1,55 +1,30 @@ import { css } from '@leafygreen-ui/emotion'; import { createUniqueClassName, Theme } from '@leafygreen-ui/lib'; -import { palette } from '@leafygreen-ui/palette'; +import { color } from '@leafygreen-ui/tokens'; export const tabListElementClassName = createUniqueClassName('tab-list'); export const tabPanelsElementClassName = createUniqueClassName('tab-panels'); -// Using a background allows the "border" to appear underneath the individual tab color -export const modeColors = { - [Theme.Light]: { - underlineColor: css` - background: linear-gradient( - 0deg, - ${palette.gray.light2} 1px, - rgb(255 255 255 / 0%) 1px - ); - `, - }, - - [Theme.Dark]: { - underlineColor: css` - background: linear-gradient( - 0deg, - ${palette.gray.dark2} 1px, - rgb(255 255 255 / 0%) 1px - ); - `, - }, -}; - export const tabContainerStyle = css` display: flex; align-items: stretch; justify-content: space-between; `; -export const inlineChildrenContainerStyle = css` - display: flex; -`; - -export const inlineChildrenWrapperStyle = css` - display: flex; - align-items: center; -`; - -export const listStyle = css` +export const getListThemeStyles = (theme: Theme) => css` list-style: none; padding: 0; display: flex; width: 100%; overflow-x: auto; + /* Using a background allows the "border" to appear underneath the individual tab color */ + background: linear-gradient( + 0deg, + ${color[theme].border.secondary.default} 1px, + rgb(255 255 255 / 0%) 1px + ); + /* Remove scrollbar */ /* Chrome, Edge, Safari and Opera */ @@ -60,3 +35,12 @@ export const listStyle = css` -ms-overflow-style: none; /* IE */ scrollbar-width: none; /* Firefox */ `; + +export const inlineChildrenContainerStyle = css` + display: flex; +`; + +export const inlineChildrenWrapperStyle = css` + display: flex; + align-items: center; +`; diff --git a/packages/tabs/src/Tabs/Tabs.tsx b/packages/tabs/src/Tabs/Tabs.tsx index 60553ceeba..9ecdf30d44 100644 --- a/packages/tabs/src/Tabs/Tabs.tsx +++ b/packages/tabs/src/Tabs/Tabs.tsx @@ -1,8 +1,13 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import PropTypes from 'prop-types'; import { validateAriaLabelProps } from '@leafygreen-ui/a11y'; +import { + DescendantsProvider, + useInitDescendants, +} from '@leafygreen-ui/descendants'; import { cx } from '@leafygreen-ui/emotion'; +import { useIdAllocator } from '@leafygreen-ui/hooks'; import LeafyGreenProvider, { useDarkMode, } from '@leafygreen-ui/leafygreen-provider'; @@ -11,13 +16,15 @@ import { BaseFontSize } from '@leafygreen-ui/tokens'; import { useUpdatedBaseFontSize } from '@leafygreen-ui/typography'; import { LGIDS_TABS } from '../constants'; -import { InternalTab } from '../Tab'; +import { TabDescendantsContext, TabPanelDescendantsContext } from '../context'; +import TabPanel from '../TabPanel'; +import TabTitle from '../TabTitle'; +import { getActiveAndEnabledIndices } from '../utils'; import { + getListThemeStyles, inlineChildrenContainerStyle, inlineChildrenWrapperStyle, - listStyle, - modeColors, tabContainerStyle, tabListElementClassName, tabPanelsElementClassName, @@ -35,164 +42,166 @@ import { AccessibleTabsProps } from './Tabs.types'; Tab 2 ``` + * @param props.as HTML Element that wraps name in Tab List. * @param props.children Content to appear inside of Tabs component. - * @param props.setSelected Callback to be executed when Tab is selected. Receives index of activated Tab as the first argument. - * @param props.selected Index of the Tab that should appear active. If value passed, component will be controlled by consumer. * @param props.className className applied to Tabs container. - * @param props.as HTML Element that wraps name in Tab List. + * @param props.selected Index of the Tab that should appear active. If value passed, component will be controlled by consumer. + * @param props.setSelected Callback to be executed when Tab is selected. Receives index of activated Tab as the first argument. */ -function Tabs(props: AccessibleTabsProps) { +const Tabs = (props: AccessibleTabsProps) => { validateAriaLabelProps(props, 'Tabs'); const { - children, - inlineChildren, - className, as = 'button', baseFontSize: baseFontSizeProp, - setSelected: setControlledSelected, - selected: controlledSelected, + children, + className, darkMode: darkModeProp, + inlineChildren, + selected: controlledSelected, + setSelected: setControlledSelected, 'data-lgid': dataLgId = LGIDS_TABS.root, 'aria-labelledby': ariaLabelledby, 'aria-label': ariaLabel, ...rest } = props; + const baseFontSize: BaseFontSize = useUpdatedBaseFontSize(baseFontSizeProp); const { theme, darkMode } = useDarkMode(darkModeProp); + const id = useIdAllocator({ prefix: rest.id || 'tabs' }); + + const { descendants: tabDescendants, dispatch: tabDispatch } = + useInitDescendants(); + const { descendants: tabPanelDescendants, dispatch: tabPanelDispatch } = + useInitDescendants(); - const [tabNode, setTabNode] = useState(null); - const [panelNode, setPanelNode] = useState(null); + const isControlled = typeof controlledSelected !== 'undefined'; + const [uncontrolledSelected, setUncontrolledSelected] = useState(0); + const [selected, setSelected] = [ + isControlled ? controlledSelected : uncontrolledSelected, + isControlled ? setControlledSelected : setUncontrolledSelected, + ]; const accessibilityProps = { ['aria-label']: ariaLabel, ['aria-labelledby']: ariaLabelledby, }; - const childrenArray = useMemo( - () => React.Children.toArray(children) as Array, - [children], - ); - - const isControlled = typeof controlledSelected === 'number'; - const [uncontrolledSelected, setUncontrolledSelected] = useState( - childrenArray.findIndex(child => child.props.default || 0), - ); - const selected = isControlled ? controlledSelected : uncontrolledSelected; - const setSelected = isControlled - ? setControlledSelected - : setUncontrolledSelected; - - const handleChange = useCallback( + const handleClickTab = useCallback( (e: React.SyntheticEvent, index: number) => { setSelected?.(index); }, [setSelected], ); - const getEnabledIndexes: () => [Array, number] = useCallback(() => { - const enabledIndexes = childrenArray - .filter(child => !child.props.disabled) - .map(child => childrenArray.indexOf(child)); - - return [enabledIndexes, enabledIndexes.indexOf(selected!)]; - }, [childrenArray, selected]); + const tabTitleElements = tabDescendants.map( + descendant => descendant.element.parentNode as HTMLElement, + ); - const handleArrowKeyPress = useCallback( + const handleKeyDown = useCallback( (e: KeyboardEvent) => { - if (!(e.metaKey || e.ctrlKey)) { - if (e.key === keyMap.ArrowRight) { - const [enabledIndexes, current] = getEnabledIndexes(); - setSelected?.(enabledIndexes[(current + 1) % enabledIndexes.length]); - } else if (e.key === keyMap.ArrowLeft) { - const [enabledIndexes, current] = getEnabledIndexes(); - setSelected?.( - enabledIndexes[ - (current - 1 + enabledIndexes.length) % enabledIndexes.length - ], - ); - } - } + if (e.metaKey || e.ctrlKey) return; + + if (e.key !== keyMap.ArrowRight && e.key !== keyMap.ArrowLeft) return; + + const { activeIndex, enabledIndices } = getActiveAndEnabledIndices( + tabTitleElements, + selected, + ); + const numberOfEnabledTabs = enabledIndices.length; + const indexToUpdateTo = + enabledIndices[ + (e.key === keyMap.ArrowRight + ? activeIndex + 1 + : activeIndex - 1 + numberOfEnabledTabs) % numberOfEnabledTabs + ]; + setSelected?.(indexToUpdateTo); + tabTitleElements[indexToUpdateTo].focus(); }, - [getEnabledIndexes, setSelected], + [selected, setSelected, tabTitleElements], ); - const renderedTabs = React.Children.map(children, (child, index) => { + const renderedTabs = React.Children.map(children, child => { if (!isComponentType(child, 'Tab')) { return child; } - const isTabSelected = index === selected; - const { disabled, onClick, onKeyDown, className, ...rest } = child.props; + const { disabled, onClick, onKeyDown, name, ...rest } = child.props; const tabProps = { as, disabled, darkMode, - parentRef: tabNode, - className, + name, onKeyDown: (event: KeyboardEvent) => { onKeyDown?.(event); - handleArrowKeyPress(event); + handleKeyDown(event); }, onClick: !disabled - ? (event: React.MouseEvent) => { + ? (event: React.MouseEvent, index: number) => { onClick?.(event); - handleChange(event, index); + handleClickTab(event, index); } : undefined, + selectedIndex: selected, ...rest, - }; - - return ( - // Since the children contain both the tab title and content, - // and since we want these elements to be in different places in the DOM - // we use a Portal in InternalTab to place the conten in the correct spot - - ); + } as const; + + return {name}; + }); + + const renderedTabPanels = React.Children.map(children, child => { + if (!isComponentType(child, 'Tab')) { + return child; + } + + return ; }); return ( -
- {/* render the portaled contents */} - {renderedTabs} - -
- {/* renderedTabs portals the tab title into this element */} -
-
-
{inlineChildren}
+ + +
+
+
+ {renderedTabs} +
+
+
+ {inlineChildren} +
+
+
+
+ {renderedTabPanels} +
-
- - {/* renderedTabs portals the contents into this element */} -
-
+ + ); -} +}; Tabs.displayName = 'Tabs'; diff --git a/packages/tabs/src/context/TabDescendantsContext.ts b/packages/tabs/src/context/TabDescendantsContext.ts new file mode 100644 index 0000000000..13599feead --- /dev/null +++ b/packages/tabs/src/context/TabDescendantsContext.ts @@ -0,0 +1,17 @@ +import { + createDescendantsContext, + useDescendantsContext, +} from '@leafygreen-ui/descendants'; + +export const TabDescendantsContext = createDescendantsContext( + 'TabDescendantsContext', +); + +/** + * Access list of tab descendants + */ +export function useTabDescendantsContext() { + const { descendants } = useDescendantsContext(TabDescendantsContext); + + return { tabDescendants: descendants }; +} diff --git a/packages/tabs/src/context/TabPanelDescendantsContext.ts b/packages/tabs/src/context/TabPanelDescendantsContext.ts new file mode 100644 index 0000000000..1983a3bd69 --- /dev/null +++ b/packages/tabs/src/context/TabPanelDescendantsContext.ts @@ -0,0 +1,16 @@ +import { + createDescendantsContext, + useDescendantsContext, +} from '@leafygreen-ui/descendants'; + +export const TabPanelDescendantsContext = + createDescendantsContext('TabPanelsDescendantsContext'); + +/** + * Access list of tab panel descendants + */ +export function useTabPanelDescendantsContext() { + const { descendants } = useDescendantsContext(TabPanelDescendantsContext); + + return { tabPanelDescendants: descendants }; +} diff --git a/packages/tabs/src/context/index.ts b/packages/tabs/src/context/index.ts new file mode 100644 index 0000000000..8b3ee5c7ef --- /dev/null +++ b/packages/tabs/src/context/index.ts @@ -0,0 +1,8 @@ +export { + TabDescendantsContext, + useTabDescendantsContext, +} from './TabDescendantsContext'; +export { + TabPanelDescendantsContext, + useTabPanelDescendantsContext, +} from './TabPanelDescendantsContext'; diff --git a/packages/tabs/src/utils/getActiveAndEnabledIndices/getActiveAndEnabledIndices.spec.tsx b/packages/tabs/src/utils/getActiveAndEnabledIndices/getActiveAndEnabledIndices.spec.tsx new file mode 100644 index 0000000000..cb4a864c20 --- /dev/null +++ b/packages/tabs/src/utils/getActiveAndEnabledIndices/getActiveAndEnabledIndices.spec.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import { Tab, Tabs } from '../..'; +import { getTestUtils } from '..'; + +import { getActiveAndEnabledIndices } from './getActiveAndEnabledIndices'; + +const renderTabs = ( + tabsProps = {}, + { disableFirst = false, disableSecond = false, disableThird = false }, +) => { + const renderUtils = render( + + + Content 1 + + + Content 2 + + + Content 3 + + , + ); + + const testUtils = getTestUtils(); + + return { + ...renderUtils, + ...testUtils, + }; +}; + +describe('getActiveAndEnabledIndices', () => { + test('should return correct activeIndex and enabledIndices for enabled tabs', () => { + const selected = 2; + const { getAllTabsInTabList } = renderTabs( + { selected }, + { disableSecond: true }, + ); + const tabTitleElements = getAllTabsInTabList(); + + const { activeIndex, enabledIndices } = getActiveAndEnabledIndices( + tabTitleElements, + selected, + ); + + expect(activeIndex).toBe(1); + expect(enabledIndices).toEqual([0, 2]); + }); + + test('should return correct activeIndex and enabledIndices when all tabs are enabled', () => { + const selected = 1; + const { getAllTabsInTabList } = renderTabs({ selected }, {}); + const tabTitleElements = getAllTabsInTabList(); + + const { activeIndex, enabledIndices } = getActiveAndEnabledIndices( + tabTitleElements, + selected, + ); + + expect(activeIndex).toBe(1); + expect(enabledIndices).toEqual([0, 1, 2]); + }); + + test('should return correct activeIndex and enabledIndices when no tabs are enabled', () => { + const selected = 2; + const { getAllTabsInTabList } = renderTabs( + { selected }, + { disableFirst: true, disableSecond: true, disableThird: true }, + ); + const tabTitleElements = getAllTabsInTabList(); + + const { activeIndex, enabledIndices } = getActiveAndEnabledIndices( + tabTitleElements, + selected, + ); + + expect(activeIndex).toBe(-1); + expect(enabledIndices).toEqual([]); + }); +}); diff --git a/packages/tabs/src/utils/getActiveAndEnabledIndices/getActiveAndEnabledIndices.ts b/packages/tabs/src/utils/getActiveAndEnabledIndices/getActiveAndEnabledIndices.ts new file mode 100644 index 0000000000..d3fce8a07b --- /dev/null +++ b/packages/tabs/src/utils/getActiveAndEnabledIndices/getActiveAndEnabledIndices.ts @@ -0,0 +1,12 @@ +export function getActiveAndEnabledIndices( + tabTitleElements: Array, + selected: number, +) { + const enabledIndices = tabTitleElements + .filter(tabTitleEl => !tabTitleEl.hasAttribute('disabled')) + .map(tabTitleEl => tabTitleElements.indexOf(tabTitleEl)); + + const activeIndex = enabledIndices.indexOf(selected); + + return { activeIndex, enabledIndices }; +} diff --git a/packages/tabs/src/utils/getActiveAndEnabledIndices/index.ts b/packages/tabs/src/utils/getActiveAndEnabledIndices/index.ts new file mode 100644 index 0000000000..037cdd3f1d --- /dev/null +++ b/packages/tabs/src/utils/getActiveAndEnabledIndices/index.ts @@ -0,0 +1 @@ +export { getActiveAndEnabledIndices } from './getActiveAndEnabledIndices'; diff --git a/packages/tabs/src/utils/index.ts b/packages/tabs/src/utils/index.ts index baf91cfc9f..947711c11a 100644 --- a/packages/tabs/src/utils/index.ts +++ b/packages/tabs/src/utils/index.ts @@ -1 +1,2 @@ +export { getActiveAndEnabledIndices } from './getActiveAndEnabledIndices'; export { getTestUtils } from './getTestUtils/getTestUtils'; diff --git a/packages/tabs/tsconfig.json b/packages/tabs/tsconfig.json index 7f54c6801c..32c26fe98e 100644 --- a/packages/tabs/tsconfig.json +++ b/packages/tabs/tsconfig.json @@ -21,6 +21,9 @@ { "path": "../box" }, + { + "path": "../descendants" + }, { "path": "../emotion" }, @@ -33,9 +36,6 @@ { "path": "../palette" }, - { - "path": "../portal" - }, { "path": "../tokens" }, diff --git a/yarn.lock b/yarn.lock index 510a609fa4..aa1632eee5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7453,9 +7453,9 @@ camelcase@^7.0.0: integrity sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw== caniuse-lite@^1.0.30001406, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001585, caniuse-lite@^1.0.30001587: - version "1.0.30001614" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001614.tgz#f894b4209376a0bf923d67d9c361d96b1dfebe39" - integrity sha512-jmZQ1VpmlRwHgdP1/uiKzgiAuGOfLEJsYFP4+GBou/QQ4U6IOJCB4NP1c+1p9RGLpwObcT94jA5/uO+F1vBbog== + version "1.0.30001620" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001620.tgz#78bb6f35b8fe315b96b8590597094145d0b146b4" + integrity sha512-WJvYsOjd1/BYUY6SNGUosK9DUidBPDTnOARHp3fSmFO1ekdxaY6nKRttEVrfMmYi80ctS0kz1wiWmm14fVc3ew== case-sensitive-paths-webpack-plugin@^2.4.0: version "2.4.0"