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}
+
+
+
+
+
+ {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"