Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 113 additions & 11 deletions src/components/Tabs/Tab/Tab.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
'use client';

import React, { FC, Ref, useEffect, useRef } from 'react';
import React, { FC, Ref, useEffect, useRef, useState } from 'react';
import { mergeClasses } from '../../../shared/utilities';
import { TabIconAlign, TabProps, TabSize, TabVariant } from '../Tabs.types';
import {
TabIconAlign,
TabProps,
TabSize,
TabVariant,
TabVariantType,
} from '../Tabs.types';
import { useTabs } from '../Tabs.context';
import { Flipped } from 'react-flip-toolkit';

import { Icon, IconSize } from '../../Icon';
import { Icon, IconSize, IconName } from '../../Icon';
import { ThemeNames, useConfig } from '../../ConfigProvider';
import { Badge } from '../../Badge';
import { Loader } from '../../Loader';
import { useCanvasDirection } from '../../../hooks/useCanvasDirection';
import { Dropdown } from '../../Dropdown';
import { Menu, MenuSize } from '../../Menu';
import { MenuItemType } from '../../Menu/MenuItem/MenuItem.types';

import { useMergedRefs } from '../../../hooks/useMergedRefs';
import styles from '../tabs.module.scss';
Expand All @@ -28,13 +37,18 @@ export const Tab: FC<TabProps> = React.forwardRef(
value,
ariaControls,
index = 0,
variant: tabVariant = TabVariantType.default,
dropdownItems,
dropdownProps,
...rest
},
ref: Ref<HTMLButtonElement>
) => {
const htmlDir: string = useCanvasDirection();
const tabRef = useRef(null);
const combinedRef = useMergedRefs(ref, tabRef);
const [dropdownVisible, setDropdownVisible] = useState(false);
const hasDropdown = tabVariant === TabVariantType.dropdown;

const {
alignIcon,
Expand All @@ -52,7 +66,10 @@ export const Tab: FC<TabProps> = React.forwardRef(

const iconExists: boolean = !!icon;
const labelExists: boolean = !!label;
const isActive: boolean = value === currentActiveTab;
// Tab is active if its value matches currentActiveTab OR if any of its dropdown items matches
const isActive: boolean =
value === currentActiveTab ||
(hasDropdown && dropdownItems?.some((item) => item.value === currentActiveTab));

const { registeredTheme: { light = false } = {} } = useConfig();

Expand Down Expand Up @@ -121,12 +138,59 @@ export const Tab: FC<TabProps> = React.forwardRef(
const getLoader = (): JSX.Element =>
loading && <Loader classNames={styles.loader} />;

const getChevronIcon = (): JSX.Element =>
hasDropdown && (
<Icon
path={IconName.mdiChevronDown}
classNames={mergeClasses([
styles.dropdownChevron,
{ [styles.dropdownChevronOpen]: dropdownVisible },
])}
size={IconSize.Small}
/>
);

const handleTabKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (enableArrowNav && index !== undefined) {
handleKeyDown?.(e, index);
}
};

const handleTabClick = (e: React.MouseEvent<HTMLButtonElement>) => {
onTabClick(value, e);
};

const handleDropdownItemClick = (itemValue: string) => {
onTabClick(itemValue, {
currentTarget: tabRef.current,
} as React.MouseEvent<HTMLElement>);
setDropdownVisible(false);
};

const getDropdownOverlay = (): JSX.Element => {
if (!hasDropdown) return null;

const menuItems = dropdownItems.map((item) => ({
text: item.label,
value: item.value,
disabled: item.disabled,
iconProps: item.icon ? { path: item.icon } : undefined,
type: MenuItemType.button,
ariaLabel: item.ariaLabel || item.label,
role: 'menuitem',
}));

return (
<Menu
items={menuItems}
onChange={handleDropdownItemClick}
role="menu"
size={MenuSize.medium}
/>
);
};


const getTabIndex = () => {
const isActiveTabIndex = isActive ? 0 : -1;

Expand All @@ -144,24 +208,30 @@ export const Tab: FC<TabProps> = React.forwardRef(
while (disabledTabIndexes.includes(leastActiveIndex)) {
leastActiveIndex++;
}
console.log('disabledTabIndexes>> :', disabledTabIndexes);
console.log('leastActiveIndex>> :', leastActiveIndex);

// Return 0 for the least enabled index, -1 for others
return index === leastActiveIndex ? 0 : -1;
};

return (
const tabButton = (
<button
{...rest}
aria-controls={ariaControls}
ref={combinedRef}
className={tabClassNames}
aria-controls={
hasDropdown
? `${ariaControls || `tab-dropdown-${value}`}`
: ariaControls
}
aria-label={ariaLabel}
aria-selected={isActive}
{...(hasDropdown && {
'aria-expanded': dropdownVisible,
'aria-haspopup': 'menu',
})}
ref={combinedRef}
className={tabClassNames}
role="tab"
disabled={disabled}
onClick={(e) => onTabClick(value, e)}
onClick={handleTabClick}
onKeyDown={handleTabKeyDown}
tabIndex={getTabIndex()}
data-index={index}
Expand All @@ -172,8 +242,40 @@ export const Tab: FC<TabProps> = React.forwardRef(
{getTabIndicator()}
{getBadge()}
{alignIcon === TabIconAlign.End && getIcon()}
{getChevronIcon()}
{getLoader()}
</button>
);

if (hasDropdown) {
const dropdownId = `tab-dropdown-${value}`;
return (
<Dropdown
trigger="hover"
visible={dropdownVisible}
closeOnDropdownClick={true}
closeOnOutsideClick={true}
closeOnReferenceClick={false}
ariaHaspopupValue="menu"
role="menu"
initialFocus
offset={-0.5}
{...dropdownProps}
overlay={getDropdownOverlay()}
referenceOnClick={handleTabClick}
onVisibleChange={(visible) => {
setDropdownVisible(visible);
dropdownProps?.onVisibleChange?.(visible);
}}
>
{React.cloneElement(tabButton, {
'aria-controls': dropdownId,
onClick: handleTabClick,
})}
</Dropdown>
);
}

return tabButton;
}
);
38 changes: 37 additions & 1 deletion src/components/Tabs/Tabs.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Stories } from '@storybook/addon-docs';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { Tabs, Tab, TabIconAlign, TabSize, TabVariant } from './';
import { Tabs, Tab, TabIconAlign, TabSize, TabVariant, TabVariantType } from './';
import { IconName } from '../Icon';

export default {
Expand Down Expand Up @@ -99,6 +99,35 @@ const iconLabelTabs = [1, 2, 3, 4].map((i) => ({
...(i === 4 ? { disabled: true } : {}),
}));

const dropdownTabs = [
{
value: 'tab1',
label: 'Tab 1',
ariaLabel: 'Tab 1',
},
{
value: 'tab2',
label: 'Tab 2',
ariaLabel: 'Tab 2',
variant: TabVariantType.dropdown,
dropdownItems: [
{ value: 'tab2-1', label: 'Sub Tab 2-1', ariaLabel: 'Sub Tab 2-1' },
{ value: 'tab2-2', label: 'Sub Tab 2-2', ariaLabel: 'Sub Tab 2-2' },
{ value: 'tab2-3', label: 'Sub Tab 2-3', ariaLabel: 'Sub Tab 2-3', disabled: true },
],
},
{
value: 'tab3',
label: 'Tab 3',
ariaLabel: 'Tab 3',
},
{
value: 'tab4',
label: 'Tab 4',
ariaLabel: 'Tab 4',
},
];

const scrollableTabs = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((i) => ({
value: `tab${i}`,
label: `Tab ${i}`,
Expand Down Expand Up @@ -149,6 +178,7 @@ export const Pill_Default = Tabs_Story.bind({});
export const Pill_With_Badge = Tabs_Story.bind({});
export const Pill_Icon = Tabs_Story.bind({});
export const Pill_Icon_Label = Tabs_Story.bind({});
export const With_Dropdown = Tabs_Story.bind({});

// Storybook 6.5 using Webpack >= 5.76.0 automatically alphabetizes exports,
// this line ensures they are exported in the desired order.
Expand All @@ -167,6 +197,7 @@ export const __namedExportsOrder = [
'Pill_With_Badge',
'Pill_Icon',
'Pill_Icon_Label',
'With_Dropdown',
];

const tabsArgs: Object = {
Expand Down Expand Up @@ -262,3 +293,8 @@ Pill_Icon_Label.args = {
variant: TabVariant.pill,
children: iconLabelTabs.map((tab) => <Tab key={tab.value} {...tab} />),
};

With_Dropdown.args = {
...tabsArgs,
children: dropdownTabs.map((tab) => <Tab key={tab.value} {...tab} />),
};
Loading