Skip to content
6 changes: 6 additions & 0 deletions packages/lib/src/tabs/Tabs.accessibility.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import { render } from "@testing-library/react";
import { axe } from "../../test/accessibility/axe-helper";
import DxcTabs from "./Tabs";

(global as any).ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};

const iconSVG = (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" height="20" width="20" fill="currentColor">
<path d="m10 17-1.042-.938q-2.083-1.854-3.437-3.177-1.354-1.323-2.136-2.354Q2.604 9.5 2.302 8.646 2 7.792 2 6.896q0-1.854 1.271-3.125T6.396 2.5q1.021 0 1.979.438.958.437 1.625 1.229.667-.792 1.625-1.229.958-.438 1.979-.438 1.854 0 3.125 1.271T18 6.896q0 .896-.292 1.729-.291.833-1.073 1.854-.781 1.021-2.145 2.365-1.365 1.344-3.49 3.26Zm0-2.021q1.938-1.729 3.188-2.948 1.25-1.219 1.989-2.125.74-.906 1.031-1.614.292-.709.292-1.396 0-1.229-.833-2.063Q14.833 4 13.604 4q-.729 0-1.364.302-.636.302-1.094.844L10.417 6h-.834l-.729-.854q-.458-.542-1.114-.844Q7.083 4 6.396 4q-1.229 0-2.063.833-.833.834-.833 2.063 0 .687.271 1.364.271.678.989 1.573.719.896 1.98 2.125Q8 13.188 10 14.979Zm0-5.5Z" />
Expand Down
42 changes: 41 additions & 1 deletion packages/lib/src/tabs/Tabs.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const tabs = (margin?: Space | Margin) => (
<DxcTabs.Tab label="Tab 4">
<></>
</DxcTabs.Tab>
<DxcTabs.Tab label="Tab 5">
<DxcTabs.Tab label="Tab 5" title="test tooltip 5">
<></>
</DxcTabs.Tab>
<DxcTabs.Tab label="Tab 6">
Expand Down Expand Up @@ -281,6 +281,36 @@ const Scroll = () => (
</>
);

const ResponsiveFocused = () => (
<>
<ExampleContainer>
<DxcTabs>
<DxcTabs.Tab label="Tab 1" title="test tooltip">
<></>
</DxcTabs.Tab>
<DxcTabs.Tab label="Tab 2">
<></>
</DxcTabs.Tab>
<DxcTabs.Tab label="Tab 3" disabled>
<></>
</DxcTabs.Tab>
<DxcTabs.Tab label="Tab 4">
<></>
</DxcTabs.Tab>
<DxcTabs.Tab label="Tab 5" title="test tooltip 5">
<></>
</DxcTabs.Tab>
<DxcTabs.Tab label="Tab 6">
<></>
</DxcTabs.Tab>
<DxcTabs.Tab label="Tab 7" defaultActive>
<></>
</DxcTabs.Tab>
</DxcTabs>
</ExampleContainer>
</>
);

type Story = StoryObj<typeof DxcTabs>;

export const Chromatic: Story = {
Expand All @@ -301,3 +331,13 @@ export const ScrollableTabs: Story = {
chromatic: { viewports: [375], delay: 5000 },
},
};

export const ResponsiveFocusedTabs: Story = {
render: ResponsiveFocused,
parameters: {
viewport: {
defaultViewport: "iphonex",
},
chromatic: { viewports: [375], delay: 5000 },
},
};
86 changes: 39 additions & 47 deletions packages/lib/src/tabs/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
KeyboardEvent,
ReactElement,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
Expand Down Expand Up @@ -81,26 +82,13 @@ const TabsContent = styled.div`
const ScrollableTabsList = styled.div<{
enabled: boolean;
iconPosition: TabsPropsType["iconPosition"];
translateScroll: number;
}>`
display: flex;
${({ enabled, translateScroll }) =>
enabled ? `transform: translateX(${translateScroll}px)` : "transform: translateX(0px)"};
transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
height: ${({ iconPosition }) => (iconPosition === "top" ? "72px" : "var(--height-xxl)")};
`;

const DxcTabs = ({
activeTabIndex,
children,
defaultActiveTabIndex,
iconPosition = "left",
margin,
onTabClick,
onTabHover,
tabIndex = 0,
tabs,
}: TabsPropsType) => {
const DxcTabs = ({ children, iconPosition = "left", margin, tabIndex = 0 }: TabsPropsType) => {
const childrenArray: ReactElement<TabProps>[] = useMemo(
() => Children.toArray(children) as ReactElement<TabProps>[],
[children]
Expand All @@ -117,12 +105,11 @@ const DxcTabs = ({

return isValidElement(initialActiveTab) ? (initialActiveTab.props.label ?? initialActiveTab.props.tabId) : "";
});
const [countClick, setCountClick] = useState(0);
const [innerFocusIndex, setInnerFocusIndex] = useState<number | null>(null);
const [scrollLeftEnabled, setScrollLeftEnabled] = useState(false);
const [scrollRightEnabled, setScrollRightEnabled] = useState(true);
const [translateScroll, setTranslateScroll] = useState(0);
const [totalTabsWidth, setTotalTabsWidth] = useState(0);
const refTabListContainer = useRef<HTMLDivElement | null>(null);
const refTabList = useRef<HTMLDivElement | null>(null);
const translatedLabels = useContext(HalstackLanguageContext);
const viewWidth = useWidth(refTabList.current);
Expand All @@ -138,52 +125,51 @@ const DxcTabs = ({
};
}, [activeTabId, childrenArray, iconPosition, innerFocusIndex, tabIndex]);

const scrollLimitCheck = () => {
const container = refTabListContainer.current;
if (container) {
const currentScroll = container.scrollLeft;
const scrollingLength = container.scrollWidth - container.offsetWidth;
const startingScroll = currentScroll <= 1;
const endScroll = currentScroll >= scrollingLength - 1;

setScrollLeftEnabled(!startingScroll);
setScrollRightEnabled(!endScroll);
}
};

const scrollLeft = () => {
const offsetHeight = refTabList?.current?.offsetHeight ?? 0;
let moveX = 0;
if (countClick <= offsetHeight) {
moveX = 0;
setScrollLeftEnabled(false);
setScrollRightEnabled(true);
} else {
moveX = countClick - offsetHeight * 2;
setScrollRightEnabled(true);
setScrollLeftEnabled(true);
if (refTabListContainer.current) {
refTabListContainer.current.scrollLeft -= 100;
scrollLimitCheck();
}
setTranslateScroll(-moveX);
setCountClick(moveX);
};

const scrollRight = () => {
const offsetHeight = refTabList?.current?.offsetHeight ?? 0;
let moveX = 0;
if (countClick + offsetHeight >= totalTabsWidth) {
moveX = totalTabsWidth - offsetHeight;
setScrollRightEnabled(false);
setScrollLeftEnabled(true);
} else {
moveX = countClick + offsetHeight * 2;
setScrollLeftEnabled(true);
setScrollRightEnabled(true);
if (refTabListContainer.current) {
refTabListContainer.current.scrollLeft += 100;
scrollLimitCheck();
}
setTranslateScroll(-moveX);
setCountClick(moveX);
};

const handleOnKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const activeTab = childrenArray.findIndex(
(child: ReactElement) => (child.props.label ?? child.props.tabId) === activeTabId
);
let index;
switch (event.key) {
case "Left":
case "ArrowLeft":
event.preventDefault();
setInnerFocusIndex(getPreviousTabIndex(childrenArray, innerFocusIndex === null ? activeTab : innerFocusIndex));
index = getPreviousTabIndex(childrenArray, innerFocusIndex === null ? activeTab : innerFocusIndex);
setInnerFocusIndex(index);

break;
case "Right":
case "ArrowRight":
event.preventDefault();
setInnerFocusIndex(getNextTabIndex(childrenArray, innerFocusIndex === null ? activeTab : innerFocusIndex));
index = getNextTabIndex(childrenArray, innerFocusIndex === null ? activeTab : innerFocusIndex);
setInnerFocusIndex(index);
break;
case "Tab":
if (activeTab !== innerFocusIndex) {
Expand All @@ -193,18 +179,25 @@ const DxcTabs = ({
default:
break;
}
setTimeout(() => {
scrollLimitCheck();
}, 0);
};

useLayoutEffect(() => {
useEffect(() => {
if (refTabList.current)
setTotalTabsWidth(() => {
let total = 0;
refTabList.current?.querySelectorAll('[role="tab"]').forEach((tab) => {
refTabList.current?.querySelectorAll('[role="tab"]').forEach((tab, index) => {
if (tab.ariaSelected === "true" && viewWidth && viewWidth < totalTabsWidth) {
setInnerFocusIndex(index);
}
total += (tab as HTMLElement).offsetWidth;
});
return total;
});
}, []);
scrollLimitCheck();
}, [viewWidth, totalTabsWidth]);

return (
<>
Expand All @@ -221,14 +214,13 @@ const DxcTabs = ({
<DxcIcon icon="keyboard_arrow_left" />
</ScrollIndicatorButton>
)}
<TabsContent>
<TabsContent ref={refTabListContainer}>
<ScrollableTabsList
enabled={viewWidth < totalTabsWidth}
iconPosition={iconPosition}
onKeyDown={handleOnKeyDown}
ref={refTabList}
role="tablist"
translateScroll={translateScroll}
>
<TabsContext.Provider value={contextValue}>{children}</TabsContext.Provider>
</ScrollableTabsList>
Expand Down
74 changes: 2 additions & 72 deletions packages/lib/src/tabs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,6 @@ import { ReactNode } from "react";

import type { Space, Margin, SVG } from "../common/utils";

type TabCommonProps = {
/**
* Whether the tab is disabled or not.
*/
isDisabled?: boolean;
/**
* If the value is 'true', an empty badge will appear.
* If it is 'false', no badge will appear.
* If a number is put it will be shown as the label of the notification
* in the tab, taking into account that if that number is greater than 99,
* it will appear as '+99' in the badge.
*/
notificationNumber?: boolean | number;
};

export type TabsContextProps = {
activeTabId?: string;
focusedTabId?: string;
Expand Down Expand Up @@ -48,17 +33,6 @@ export type TabIconProps = {
icon: string | SVG;
};

export type TabPropsLegacy = {
tab: TabCommonProps & (TabLabelProps | TabIconProps);
active: boolean;
tabIndex: number;
hasLabelAndIcon: boolean;
iconPosition: "top" | "left";
onClick: () => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
};

export type TabProps = {
defaultActive?: boolean;
active?: boolean;
Expand All @@ -71,51 +45,7 @@ export type TabProps = {
onHover?: () => void;
} & (TabLabelProps | TabIconProps);

type LegacyProps = {
/**
* @deprecated This prop is deprecated and will be removed in future versions. Use the children prop instead.
* The index of the active tab. If undefined, the component will be
* uncontrolled and the active tab will be managed internally by the component.
*/
activeTabIndex?: number;
/**
* @deprecated This prop is deprecated and will be removed in future versions.
* Initially active tab, only when it is uncontrolled.
*/
defaultActiveTabIndex?: number;
/**
* Whether the icon should appear above or to the left of the label.
*/
iconPosition?: "top" | "left";
/**
* Size of the margin to be applied to the component ('xxsmall' | 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge').
* You can pass an object with 'top', 'bottom', 'left' and 'right' properties in order to specify different margin sizes.
*/
margin?: Space | Margin;
/**
* @deprecated This prop is deprecated and will be removed in future versions.
* This function will be called when the user clicks on a tab. The index of the
* clicked tab will be passed as a parameter.
*/
onTabClick?: (index: number) => void;
/**
* @deprecated This prop is deprecated and will be removed in future versions.
* This function will be called when the user hovers a tab.The index of the
* hovered tab will be passed as a parameter.
*/
onTabHover?: (index: number | null) => void;
/**
* Value of the tabindex attribute applied to each tab.
*/
tabIndex?: number;
/**
* @deprecated This prop is deprecated and will be removed in future versions.
* An array of objects representing the tabs.
*/
tabs?: (TabCommonProps & (TabLabelProps | TabIconProps))[];
};

type NewProps = {
type TabsProps = {
/**
* Whether the icon should appear above or to the left of the label.
*/
Expand All @@ -135,6 +65,6 @@ type NewProps = {
children?: ReactNode;
};

type Props = LegacyProps & NewProps;
type Props = TabsProps;

export default Props;