diff --git a/apps/website/pages/_app.tsx b/apps/website/pages/_app.tsx index 2f3ff87952..83cd4ed682 100644 --- a/apps/website/pages/_app.tsx +++ b/apps/website/pages/_app.tsx @@ -30,10 +30,8 @@ const ApplicationLayoutWrapper = ({ condition, wrapper, children }: ApplicationL const App = ({ Component, pageProps }: AppPropsWithLayout) => { const getLayout = Component.getLayout || ((page) => page); const componentWithLayout = getLayout(); - const [filter, setFilter] = useState(""); const { asPath: currentPath } = useRouter(); - const filteredLinks = useMemo(() => { const filtered: LinksSectionDetails[] = []; LinksSections.map((section) => { @@ -47,17 +45,10 @@ const App = ({ Component, pageProps }: AppPropsWithLayout) => { return filtered; }, [filter]); - const onFilterInputChange = ({ value }: { value: string }) => { - setFilter(value); - }; - const matchPaths = (linkPath: string) => { + const desiredPaths = [linkPath, `${linkPath}/code`]; const pathToBeMatched = currentPath?.split("#")[0]?.slice(0, -1); - const desiredPaths = [linkPath, `${linkPath}/specifications`, `${linkPath}/usage`]; - if (pathToBeMatched) { - return desiredPaths.includes(pathToBeMatched); - } - return false; + return pathToBeMatched ? desiredPaths.includes(pathToBeMatched) : false; }; return ( @@ -77,7 +68,9 @@ const App = ({ Component, pageProps }: AppPropsWithLayout) => { { + setFilter(value); + }} size="fillParent" clearable margin={{ diff --git a/apps/website/pages/components/tabs/code.tsx b/apps/website/pages/components/tabs/code.tsx new file mode 100644 index 0000000000..b43f98c951 --- /dev/null +++ b/apps/website/pages/components/tabs/code.tsx @@ -0,0 +1,17 @@ +import Head from "next/head"; +import type { ReactElement } from "react"; +import TabsCodePage from "screens/components/tabs/code/TabsCodePage"; +import TabsPageLayout from "screens/components/tabs/TabsPageLayout"; + +const Code = () => ( + <> + + Tabs code — Halstack Design System + + + +); + +Code.getLayout = (page: ReactElement) => {page}; + +export default Code; diff --git a/apps/website/pages/components/tabs/index.tsx b/apps/website/pages/components/tabs/index.tsx index b64cef22f5..392735d82b 100644 --- a/apps/website/pages/components/tabs/index.tsx +++ b/apps/website/pages/components/tabs/index.tsx @@ -1,21 +1,17 @@ import Head from "next/head"; import type { ReactElement } from "react"; -import TabsCodePage from "screens/components/tabs/code/TabsCodePage"; +import TabsOverviewPage from "screens/components/tabs/overview/TabsOverviewPage"; import TabsPageLayout from "screens/components/tabs/TabsPageLayout"; -const Index = () => { - return ( - <> - - Tabs — Halstack Design System - - - - ); -}; +const Index = () => ( + <> + + Tabs — Halstack Design System + + + +); -Index.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; +Index.getLayout = (page: ReactElement) => {page}; export default Index; diff --git a/apps/website/pages/components/tabs/specifications.tsx b/apps/website/pages/components/tabs/specifications.tsx deleted file mode 100644 index 546e63a6e3..0000000000 --- a/apps/website/pages/components/tabs/specifications.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import Head from "next/head"; -import type { ReactElement } from "react"; -import TabsSpecsPage from "screens/components/tabs/specs/TabsSpecsPage"; -import TabsPageLayout from "screens/components/tabs/TabsPageLayout"; - -const Specifications = () => { - return ( - <> - - Tabs Specs— Halstack Design System - - - - ); -}; - -Specifications.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - -export default Specifications; diff --git a/apps/website/pages/components/tabs/usage.tsx b/apps/website/pages/components/tabs/usage.tsx deleted file mode 100644 index 4ce1860112..0000000000 --- a/apps/website/pages/components/tabs/usage.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import Head from "next/head"; -import type { ReactElement } from "react"; -import TabsUsagePage from "screens/components/tabs/usage/TabsUsagePage"; -import TabsPageLayout from "screens/components/tabs/TabsPageLayout"; - -const Usage = () => { - return ( - <> - - Tabs — Halstack Design System - - - - ); -}; - -Usage.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - -export default Usage; diff --git a/apps/website/screens/components/button/overview/ButtonOverviewPage.tsx b/apps/website/screens/components/button/overview/ButtonOverviewPage.tsx index c08ec795b4..d40f699e12 100644 --- a/apps/website/screens/components/button/overview/ButtonOverviewPage.tsx +++ b/apps/website/screens/components/button/overview/ButtonOverviewPage.tsx @@ -153,7 +153,7 @@ const sections = [ - + Default @@ -164,7 +164,7 @@ const sections = [ - + Error @@ -175,7 +175,7 @@ const sections = [ - + Info @@ -186,7 +186,7 @@ const sections = [ - + Success @@ -197,7 +197,7 @@ const sections = [ - + Warning @@ -257,7 +257,7 @@ const sections = [ Secondary actions: They can be used for less prominent actions that are not the - primary focus of the user’s interaction. + primary focus of the user's interaction. @@ -338,7 +338,7 @@ const sections = [ content: ( - Avoid ambiguity: Make sure the button’s purpose is immediately clear. Avoid vague labels + Avoid ambiguity: Make sure the button's purpose is immediately clear. Avoid vague labels like "Click here" or "Go". @@ -368,7 +368,7 @@ const sections = [ content: ( - User-centric language: Write from the user’s perspective. For actions that the user + User-centric language: Write from the user's perspective. For actions that the user performs, consider using first-person pronouns (e.g., "My profile"). diff --git a/apps/website/screens/components/tabs/TabsPageLayout.tsx b/apps/website/screens/components/tabs/TabsPageLayout.tsx index f36e61bd52..b579a2fc2e 100644 --- a/apps/website/screens/components/tabs/TabsPageLayout.tsx +++ b/apps/website/screens/components/tabs/TabsPageLayout.tsx @@ -6,9 +6,8 @@ import { ReactNode } from "react"; const TabsPageHeading = ({ children }: { children: ReactNode }) => { const tabs = [ - { label: "Code", path: "/components/tabs" }, - { label: "Usage", path: "/components/tabs/usage" }, - { label: "Specifications", path: "/components/tabs/specifications" }, + { label: "Overview", path: "/components/tabs" }, + { label: "Code", path: "/components/tabs/code" }, ]; return ( @@ -17,10 +16,11 @@ const TabsPageHeading = ({ children }: { children: ReactNode }) => { - Tabs allow the user to interact across the sections to switch from one set of content to another, making the - transition easily from one peer to the other. + A tab is a UI component that allows users to switch between different sections of content without leaving + the page. Tabs are often used to organize related information into distinct views, making it easier to + navigate between them. - + {children} diff --git a/apps/website/screens/components/tabs/code/TabsCodePage.tsx b/apps/website/screens/components/tabs/code/TabsCodePage.tsx index 3771bee221..3a4ac1b9b9 100644 --- a/apps/website/screens/components/tabs/code/TabsCodePage.tsx +++ b/apps/website/screens/components/tabs/code/TabsCodePage.tsx @@ -4,9 +4,9 @@ import QuickNavContainerLayout from "@/common/QuickNavContainerLayout"; import DocFooter from "@/common/DocFooter"; import Code from "@/common/Code"; import Example from "@/common/example/Example"; -import controlledLegacy from "./examples-old/controlled"; -import uncontrolledLegacy from "./examples-old/uncontrolled"; -import iconsLegacy from "./examples-old/icons"; +import controlledDeprecated from "./examples-old/controlled"; +import uncontrolledDeprecated from "./examples-old/uncontrolled"; +import iconsDeprecated from "./examples-old/icons"; import TableCode from "@/common/TableCode"; import StatusBadge from "@/common/StatusBadge"; import controlled from "./examples-new/controlled"; @@ -30,20 +30,7 @@ const sections = [ - - defaultActiveTabIndex - - - - number - - Initially active tab, only when it is uncontrolled. - - - - - - - + activeTabIndex @@ -59,60 +46,30 @@ const sections = [ - - tabs + {/* TODO: Swap experimental for required once old logic is removed */} + + children - - { - "{ label: string, icon: string | (React.ReactNode & React.SVGProps ), isDisabled: boolean, notificationNumber: boolean | number }[]" - } - + React.ReactNode - An array of objects representing the tabs. Each of them has the following properties: -
    -
  • - label: Tab label. -
  • -
  • - icon:{" "} - - Material Symbol - {" "} - name or SVG element used as the icon that will be displayed in the tab. When using Material Symbols, - replace spaces with underscores. By default they are outlined, if you want it to be filled prefix the - symbol name with "filled_". -
  • -
  • - isDisabled: Whether the tab is disabled or not. If the component is uncontrolled and the - selected tab is disabled, the first non-disabled tab from the array will be selected. -
  • -
  • - notificationNumber: It can have boolean type or number type. If true, an empty badge will - appear. If false or if the tab is disabled, no badge will appear. If a number is specified, the - component will display a badge with the value as its label. Take into account that if that number is - greater than 99, it will appear as +99 in the badge. -
  • -
+ Contains one or more DxcTabs.Tab. - - {/* TODO: Swap experimental for required once old logic is removed */} - - children + + defaultActiveTabIndex - React.ReactNode - - - Contains one or more DxcTabs.Tab. + number + Initially active tab, only when it is uncontrolled. - @@ -122,13 +79,24 @@ const sections = [ Whether the icon should appear above or to the left of the label. - 'top' + 'left' + + + + margin + + 'xxsmall' | 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge' | Margin + + + Size of the margin to be applied to the component. You can pass an object with 'top', 'bottom', 'left' and + 'right' properties in order to specify different margin sizes. + - - + onTabClick @@ -144,7 +112,7 @@ const sections = [ - + onTabHover @@ -158,27 +126,59 @@ const sections = [ - - margin + tabIndex - 'xxsmall' | 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge' | Margin + number - Size of the margin to be applied to the component. You can pass an object with 'top', 'bottom', 'left' and - 'right' properties in order to specify different margin sizes. + Value of the tabindex attribute applied to each tab. + + + 0 - - - tabIndex - number + + + tabs + - Value of the tabindex attribute applied to each tab. + + { + "{ label: string, icon: string | (React.ReactNode & React.SVGProps ), isDisabled: boolean, notificationNumber: boolean | number }[]" + } + - 0 + An array of objects representing the tabs. Each of them has the following properties: +
    +
  • + label: Tab label. +
  • +
  • + icon:{" "} + + Material Symbol + {" "} + name or SVG element used as the icon that will be displayed in the tab. When using Material Symbols, + replace spaces with underscores. By default they are outlined, if you want it to be filled prefix the + symbol name with "filled_". +
  • +
  • + isDisabled: Whether the tab is disabled or not. If the component is uncontrolled and the + selected tab is disabled, the first non-disabled tab from the array will be selected. +
  • +
  • + notificationNumber: It can have boolean type or number type. If true, an empty badge will + appear. If false or if the tab is disabled, no badge will appear. If a number is specified, the + component will display a badge with the value as its label. Take into account that if that number is + greater than 99, it will appear as +99 in the badge. +
  • +
+ - @@ -211,6 +211,19 @@ const sections = [ false + + + + + children + + + + React.ReactNode + + Contains the component to be rendered when this tab is active. + - + defaultActive @@ -232,44 +245,46 @@ const sections = [ + icon - - - label - + string | {"(React.ReactNode & React.SVGProps )"} - string + + Material Symbol + {" "} + name or SVG element as the icon that will be displayed in the tab. When using Material Symbols, + replace spaces with underscores. By default they are outlined if you want it to be filled prefix the + symbol name with "filled_". - Tab label text. - - title + + label string - Tooltip text for the tab. + Tab label text. - - icon + notificationNumber - string | {"(React.ReactNode & React.SVGProps )"} + boolean | number - - Material Symbol - {" "} - name or SVG element as the icon that will be displayed in the tab. When using Material Symbols, - replace spaces with underscores. By default they are outlined if you want it to be filled prefix the - symbol name with "filled_". + If true, an empty badge will appear. If false or if the tab is disabled, no badge will appear. If a + number is specified, the component will display a badge with the value as its label. Take into account + that if that number is greater than 99, it will appear as +99 in the badge. + + + false - - onClick @@ -287,31 +302,16 @@ const sections = [ This function will be called when the user hovers this tab. - - - notificationNumber - - boolean | number - - - If true, an empty badge will appear. If false or if the tab is disabled, no badge will appear. If a - number is specified, the component will display a badge with the value as its label. Take into account - that if that number is greater than 99, it will appear as +99 in the badge. - - - false - - - - children + title - React.ReactNode + string - Contains the component to be rendered when this tab is active. + Tooltip text for the tab. - @@ -338,33 +338,31 @@ const sections = [ ], }, { - title: "Examples (Legacy)", + title: "Examples (deprecated)", subSections: [ { title: "Controlled", - content: , + content: , }, { title: "Uncontrolled", - content: , + content: , }, { title: "Icons and notifications", - content: , + content: , }, ], }, ]; -const TabsUsagePage = () => { - return ( - - - - - - - ); -}; +const TabsCodePage = () => ( + + + + + + +); -export default TabsUsagePage; +export default TabsCodePage; diff --git a/apps/website/screens/components/tabs/overview/TabsOverviewPage.tsx b/apps/website/screens/components/tabs/overview/TabsOverviewPage.tsx new file mode 100644 index 0000000000..6c71061f54 --- /dev/null +++ b/apps/website/screens/components/tabs/overview/TabsOverviewPage.tsx @@ -0,0 +1,241 @@ +import { DxcParagraph, DxcBulletedList, DxcFlex, DxcLink, DxcInset } from "@dxc-technology/halstack-react"; +import Image from "@/common/Image"; +import QuickNavContainer from "@/common/QuickNavContainer"; +import QuickNavContainerLayout from "@/common/QuickNavContainerLayout"; +import DocFooter from "@/common/DocFooter"; +import defaultUsage from "./examples/default"; +import scrollableUsage from "./examples/scrollable"; +import Example from "@/common/example/Example"; +import anatomy from "./images/tabs_anatomy.png"; +import Link from "next/link"; + +const sections = [ + { + title: "Introduction", + content: ( + + Tabs are interactive UI components that allow users to navigate between different sections of content within the + same page. They help organize related information efficiently, improving usability by reducing clutter and + enabling seamless content switching. Tabs are commonly used in dashboards, settings panels, and content-heavy + applications to enhance user experience. + + ), + }, + { + title: "Anatomy", + content: ( + <> + Tabs anatomy + + + Container: the wrapper that holds all the tabs together in a row. + + + Active tab indicator: a colored line that visually marks the selected tab. + + + Divider: a subtle line separating tabs to improve readability. + + + Tab item: the clickable area that triggers a content change when selected. + + + Label (Optional if there's an icon): The text that identifies the tab's purpose or + section. + + + Icon: (Optional if there's a label): A small graphical element that enhances + visual recognition of the tab. + + + + ), + }, + { + title: "Using tabs", + content: ( + + Tabs are an effective way to organize content within a user interface by keeping related sections grouped + together while minimizing clutter. They improve usability by allowing users to quickly switch between different + views without navigating away from the page. When designed and implemented correctly, tabs enhance the user + experience by making content more accessible and easier to interact with. + + ), + subSections: [ + { + title: "Placement and alignment", + subSections: [ + { + title: "Placement", + content: ( + <> + + There are two types of tabs: Default and Container. Both types share + the same hierarchy and should never be nested within each other. Tabs are typically positioned above + the content, under the header or general navigation. + + + + Default tabs: used for main navigation, placed above the header, spanning 100% of + the screen width. + + + + + + Container tabs: Used for panel navigation, placed at the top of the panel, using + the full available width. Scrollable tabs are allowed when space is limited. + + + + + + + ), + }, + { + title: "Alignment", + content: ( + + Tabs are always displayed horizontally in a single row. They can be left-aligned or{" "} + centered, depending on the content and context. + + ), + }, + ], + }, + { + title: "Tabs vs. Nav tabs", + content: ( + <> + Tabs vs nav tabs + + Both tabs and{" "} + + nav tabs + {" "} + are used for navigation, but they serve different purposes and function in distinct ways. + + + ), + subSections: [ + { + title: "Tabs", + content: ( + + + Used to switch between different content sections within the same page or container. + + + Typically do not trigger a full page reload but update content dynamically. + + + ), + }, + { + title: "Nav tabs", + content: ( + <> + + + Act as primary navigation elements, often leading to different pages or sections of + an application. + + + Clicking on a nav tab may trigger a full page reload or route change. + + + + Both components improve usability, but tabs are best for grouping related content + within a page, while nav tabs help users move across different sections or pages of + an application. + + + ), + }, + ], + }, + ], + }, + { + title: "Best practices", + content: ( + + To ensure an intuitive and user-friendly experience, follow these best practices when designing and implementing + tabs: + + ), + subSections: [ + { + title: "Maintain logical order", + content: ( + + Arrange tabs in a meaningful sequence based on user needs. + Place frequently used or primary tabs first. + + ), + }, + { + title: "Keep tab labels short & clear", + content: ( + + + Use concise, descriptive labels (1-2 words) that clearly indicate the content. + + + Avoid using generic or ambiguous labels like "Info" or "More." Instead, choose specific terms that reflect + the content, such as "Account Details" for user-related settings or "Billing" for payment information. + + Prioritize readability—avoid using all caps unless necessary. + + ), + }, + { + title: "Managing the number of tabs effectively", + content: ( + + + While not a strict rule, keeping the number of tabs manageable (ideally 5-7) helps maintain clarity and + usability. + + + If additional tabs are necessary, assess the information architecture carefully and consider grouping + related items. + + + ), + }, + { + title: "Use icons thoughtfully", + content: ( + + + Ensure the icon is intuitive and clearly represents the content of the tab. While they are generally used + alongside labels, they can also be used independently. In such cases, it is crucial to choose highly + recognizable icons that clearly convey meaning without additional text. + + + When used together, the icon and label must work harmoniously to reinforce their meaning and avoid any + conflicting interpretations. + + + Avoid using overly decorative or generic icons that do not provide clear meaning, such as an abstract + shape with no context. + + + ), + }, + ], + }, +]; + +const TabsOverviewPage = () => ( + + + + + + +); + +export default TabsOverviewPage; diff --git a/apps/website/screens/components/tabs/usage/examples/default.ts b/apps/website/screens/components/tabs/overview/examples/default.ts similarity index 100% rename from apps/website/screens/components/tabs/usage/examples/default.ts rename to apps/website/screens/components/tabs/overview/examples/default.ts diff --git a/apps/website/screens/components/tabs/usage/examples/scrollable.ts b/apps/website/screens/components/tabs/overview/examples/scrollable.ts similarity index 79% rename from apps/website/screens/components/tabs/usage/examples/scrollable.ts rename to apps/website/screens/components/tabs/overview/examples/scrollable.ts index 1626c8439b..64e898439f 100644 --- a/apps/website/screens/components/tabs/usage/examples/scrollable.ts +++ b/apps/website/screens/components/tabs/overview/examples/scrollable.ts @@ -1,10 +1,10 @@ -import { DxcTabs, DxcInset, DxcFlex } from "@dxc-technology/halstack-react"; +import { DxcContainer, DxcTabs, DxcInset, DxcFlex } from "@dxc-technology/halstack-react"; const code = `() => { return ( -
+ <> @@ -19,13 +19,14 @@ const code = `() => { <> -
+
); }`; const scope = { + DxcContainer, DxcTabs, DxcInset, DxcFlex, diff --git a/apps/website/screens/components/tabs/overview/images/tabs_anatomy.png b/apps/website/screens/components/tabs/overview/images/tabs_anatomy.png new file mode 100644 index 0000000000..05863f27ef Binary files /dev/null and b/apps/website/screens/components/tabs/overview/images/tabs_anatomy.png differ diff --git a/apps/website/screens/components/tabs/specs/TabsSpecsPage.tsx b/apps/website/screens/components/tabs/specs/TabsSpecsPage.tsx deleted file mode 100644 index a767c5650f..0000000000 --- a/apps/website/screens/components/tabs/specs/TabsSpecsPage.tsx +++ /dev/null @@ -1,454 +0,0 @@ -import { DxcTable, DxcParagraph, DxcFlex, DxcLink, DxcBulletedList } from "@dxc-technology/halstack-react"; -import Image from "@/common/Image"; -import QuickNavContainer from "@/common/QuickNavContainer"; -import QuickNavContainerLayout from "@/common/QuickNavContainerLayout"; -import Figure from "@/common/Figure"; -import Code from "@/common/Code"; -import DocFooter from "@/common/DocFooter"; -import specsImage from "./images/tabs_specs.png"; -import specsFixed from "./images/tabs_fixed_specs.png"; -import specsFixed72 from "./images/tabs_fixed_specs72.png"; -import specsScrollable from "./images/tabs_scrollable.png"; -import specsNotification from "./images/tabs_notification.png"; -import statesTabs from "./images/tabs_states_specs.png"; -import specsAnatomy from "./images/tabs_anatomy.png"; -import HeaderDescriptionCell from "@/common/HeaderDescriptionCell"; - -const sections = [ - { - title: "Min / Max width", - content: ( -
- 48px height fixed tabs design specifications -
- ), - }, - { - title: "Fixed tabs", - content: ( - <> -
- 48px height fixed tabs design specifications -
-
- 72px height fixed tabs design specifications -
- - ), - }, - { - title: "Scrollable tabs", - content: ( - <> - - Tabs are horizontally scrollable when they are wider that screen, by using the scroll indicator. - -
- Scrollable tabs -
- - ), - }, - { - title: "Notification tabs", - content: ( - <> - - Notification badges are always positioned aligned with label or icon in 48px tab container and at top right of - the 72px tab container. - -
- Notification tabs -
- - ), - }, - { - title: "States", - content: ( - <> - - Tabs can get different states based on user interaction. These states are: inactive,{" "} - enabled, hover, pressed, focus and{" "} - disabled. - -
- Tab states -
- - ), - }, - { - title: "Anatomy", - content: ( - <> - Tabs anatomy - - Container - Active icon (Optional if there's a label) - Active text label (Optional if there's an icon) - Active tab indicator - Inactive icon (Optional if there's a label) - Inactive text label (Optional if there's an icon) - Tab item - Divider - - - ), - }, - { - title: "Design tokens", - subSections: [ - { - title: "Color", - content: ( - - - - Component token - Element - Core token - Value - - - - - - selectedBackgroundColor - - Tab item - - color-white - - #ffffff - - - - unselectedBackgroundColor - - Tab item:enabled - - color-white - - #ffffff - - - - hoverBackgroundColor - - Tab item:hover - - color-purple-100 - - #f2eafa - - - - pressedBackgroundColor - - Tab item:active - - color-purple-200 - - #e5d5f6 - - - - selectedFontColor - - Label - - color-purple-700 - - #5f249f - - - - unselectedFontColor - - Label - - color-grey-700 - - #666666 - - - - disabledFontColor - - Label:disabled - - color-grey-500 - - #999999 - - - - selectedIconColor - - Icon - - color-purple-700 - - #5f249f - - - - unselectedIconColor - - Icon - - color-grey-700 - - #666666 - - - - disabledIconColor - - Icon:disabled - - color-grey-500 - - #999999 - - - - focusOutline - - Tab item outline - - color-purple-700 - - #5f249f - - - - selectedUnderlineColor - - Tab item border botton - - color-purple-700 - - #5f249f - - - - dividerColor - - Separator - - color-grey-400 - - #bfbfbf - - - - ), - }, - { - title: "Typography", - content: ( - - - - Component token - Element - Core token - Value - - - - - - fontFamily - - Title - - font-family-sans - - 'Open Sans', sans-serif - - - - fontSize - - Title - - font-scale-03 - - 1rem / 16px - - - - fontStyle - - Title - - font-style-normal - - normal - - - - disabledFontStyle - - Title - - font-style-normal - - normal - - - - fontWeight - - Title:disabled - - font-weight-semibold - - 600 - - - - pressedFontWeight - - Title:active - - font-weight-semibold - - 600 - - - - fontTextTransform - - Title - - - none - - - - ), - }, - { - title: "Border", - content: ( - - - - Component token - Element - Core token - Value - - - - - - dividerThickness - - Separator - - - 1px - - - - selectedUnderlineThickness - - Separator:selected - - - 2px - - - - ), - }, - ], - }, - { - title: "Accessibility", - content: ( - <> - - Each tab must have a unique title that clearly describes tab panel content. This is particularly helpful for - users of assistive technologies so they have the necessary information to efficiently navigate the content. - - - Content authors need to ensure the content that is added to the tab panel is accessible. For example, if you - add an image to the panel you need to include alternative text to pass accessibility testing. - - - - W3C WAI-ARIA Tab Design Pattern - {" "} - covers the usage of ARIA names. - - - ), - subSections: [ - { - title: "Keyboard interactions", - content: ( - - - - key - Description - - - - - - Enter or Space - - Activates the tab if it was not activated automatically on focus. - - - - Tab - - - When focus moves into the tab list, places focus on the active tab element. When the tab list contains - the focus, moves focus to the next element in the page tab sequence outside the tablist, which is - typically either the first focusable element inside the tab panel or the tab panel itself. - - - - - Left-arrow - - - Moves focus to the previous tab. If focus is on the first tab, moves focus to the last tab. - Optionally, activates the newly focused tab. - - - - - Right-arrow - - - Moves focus to the next tab. If focus is on the last tab element, moves focus to the first tab. - Optionally, activates the newly focused tab. - - - - - ), - }, - ], - }, -]; - -const TabsSpecsPage = () => { - return ( - - - - - - - ); -}; - -export default TabsSpecsPage; diff --git a/apps/website/screens/components/tabs/specs/images/tabs_anatomy.png b/apps/website/screens/components/tabs/specs/images/tabs_anatomy.png deleted file mode 100644 index 53093010e5..0000000000 Binary files a/apps/website/screens/components/tabs/specs/images/tabs_anatomy.png and /dev/null differ diff --git a/apps/website/screens/components/tabs/specs/images/tabs_fixed_specs.png b/apps/website/screens/components/tabs/specs/images/tabs_fixed_specs.png deleted file mode 100644 index 5584fbf112..0000000000 Binary files a/apps/website/screens/components/tabs/specs/images/tabs_fixed_specs.png and /dev/null differ diff --git a/apps/website/screens/components/tabs/specs/images/tabs_fixed_specs72.png b/apps/website/screens/components/tabs/specs/images/tabs_fixed_specs72.png deleted file mode 100644 index d9ca886288..0000000000 Binary files a/apps/website/screens/components/tabs/specs/images/tabs_fixed_specs72.png and /dev/null differ diff --git a/apps/website/screens/components/tabs/specs/images/tabs_notification.png b/apps/website/screens/components/tabs/specs/images/tabs_notification.png deleted file mode 100644 index cf1ba14bd5..0000000000 Binary files a/apps/website/screens/components/tabs/specs/images/tabs_notification.png and /dev/null differ diff --git a/apps/website/screens/components/tabs/specs/images/tabs_scrollable.png b/apps/website/screens/components/tabs/specs/images/tabs_scrollable.png deleted file mode 100644 index f2bd1928d6..0000000000 Binary files a/apps/website/screens/components/tabs/specs/images/tabs_scrollable.png and /dev/null differ diff --git a/apps/website/screens/components/tabs/specs/images/tabs_specs.png b/apps/website/screens/components/tabs/specs/images/tabs_specs.png deleted file mode 100644 index da7b57a1c7..0000000000 Binary files a/apps/website/screens/components/tabs/specs/images/tabs_specs.png and /dev/null differ diff --git a/apps/website/screens/components/tabs/specs/images/tabs_states_specs.png b/apps/website/screens/components/tabs/specs/images/tabs_states_specs.png deleted file mode 100644 index f9fe8db876..0000000000 Binary files a/apps/website/screens/components/tabs/specs/images/tabs_states_specs.png and /dev/null differ diff --git a/apps/website/screens/components/tabs/usage/TabsUsagePage.tsx b/apps/website/screens/components/tabs/usage/TabsUsagePage.tsx deleted file mode 100644 index 233ab3a668..0000000000 --- a/apps/website/screens/components/tabs/usage/TabsUsagePage.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import { DxcParagraph, DxcBulletedList, DxcFlex } from "@dxc-technology/halstack-react"; -import Image from "@/common/Image"; -import QuickNavContainer from "@/common/QuickNavContainer"; -import QuickNavContainerLayout from "@/common/QuickNavContainerLayout"; -import Figure from "@/common/Figure"; -import DocFooter from "@/common/DocFooter"; -import defaultUsage from "./examples/default"; -import scrollableUsage from "./examples/scrollable"; -import contentUsageTabImage from "./images/tabs_content.png"; -import typographyUsageTabImage from "./images/tabs_typography.png"; -import tabsPlacement from "./images/tabs_placement.png"; -import scrollablePanelNavigation from "./images/tabs_scrollable_panel_navigation.png"; -import mobileNavigation from "./images/tabs_mobile_navigation.png"; -import panelNavigation from "./images/tabs_panel_navigation.png"; -import tabsAlignment from "./images/tabs_alignment.png"; -import tabsPanelBehavior from "./images/tabs_panel_behavior.png"; -import tabsScrollablePanelBehavior from "./images/tabs_scrollable_panel_behavior.png"; -import Example from "@/common/example/Example"; - -const sections = [ - { - title: "Usage", - content: ( - <> - - Tabs organize and allow navigation between groups of content that are related and at the same level of - hierarchy. - - - ), - }, - { - title: "Do's", - content: ( - - - Use tabs for navigation when dividing content into different sections. - - The content should have the same level of hierarchy. - - Tabs can contain icons and text. Text labels should be short and have a clear relation to content. - - - ), - }, - { - title: "Don'ts", - content: ( - - - Do not use tabs to move through sequential content that needs to be read in a particular order. - - - Avoid using tabs for comparing content across multiple tabs, such as different sizes of the same item. - - - ), - }, - { - title: "Placement and alignment", - subSections: [ - { - title: "Placement", - content: ( - <> - - There are two variations of tabs, default and container. They are hierarchically the same and should never - be nested within each other. Tabs are usually placed above the content under the header o general - navigation. - -
- - Left: A standalone tab that can also be nested within components. - - - Right: Emphasized tab always paired with an attached background container. - - - } - > - Placement -
- - ), - subSections: [ - { - title: "Desktop", - content: ( - <> - - Default - - - When used for main navigation place tabs above the header using 100% of the width of the screen. - - - - - Container - - - When used for panel navigation place tabs in the top of the panel using all available width. - Scrollable tabs are allowed when there is not enough space available. - -
- Using scrollable tabs for panel navigation -
- - ), - }, - { - title: "Mobile", - content: ( - <> - - Main navigation - - - When used for main navigation place tabs above the header using 100% of the width of the screen. - -
- - Left: Main navigation tabs are place above the content. - - - Right: Don't stack more than 4 fixed tabs. - - - } - > - Mobile main navigation -
- - Panel navigation - -
- Mobile panel nagivation -
- - ), - }, - ], - }, - { - title: "Alignment", - content: ( - <> - - Tabs are always displayed horizontally in a single row. They can be left aligned or entered depending on - the content and context. - -
- - Left: Tabs are always displayed in a single row. - - - } - > - Alignment -
- - ), - }, - ], - }, - { - title: "Content", - content: ( - <> -
- - Left: Text labels should clearly and succinctly describe the content of the tab they - represent. - - - Center: Tab content should contain a cohesive set of items that share a common - characteristics. - - - Right: Tab labels should appear in a single row. They can use a second line if needed, - with truncated text. - - - } - > - Tabs content -
- - ), - subSections: [ - { - title: "Typography", - content: ( - <> - - Avoid to use all caps for tab labels. ALL CAPS is rarely a good idea because it's harder to read. - -
- Typography -
- - ), - }, - ], - }, - { - title: "Behavior and interaction", - subSections: [ - { - title: "Main navigation", - content: ( - - Users can navigate between tabs by tapping a tab, or by performing a swipe gesture over content in mobile - devices. - - ), - }, - { - title: "Panel navigation", - content: ( - <> - - Interacting with the tabs makes the content scrolls to the point that is associated with that specific - tab, while the tabs keep fixed at the top of the container. - -
- Panel navigation -
-
- - The use of scrollable tabs in panel navigation could cause swipe interferences with OS navigation. - - Do not use main navigation if they only affect an specific panel. - - } - > - Scrollable panel -
- - ), - }, - ], - }, -]; - -const TabsUsagePage = () => { - return ( - - - - - - - ); -}; - -export default TabsUsagePage; diff --git a/apps/website/screens/components/tabs/usage/images/tabs_alignment.png b/apps/website/screens/components/tabs/usage/images/tabs_alignment.png deleted file mode 100644 index 01bb4def5a..0000000000 Binary files a/apps/website/screens/components/tabs/usage/images/tabs_alignment.png and /dev/null differ diff --git a/apps/website/screens/components/tabs/usage/images/tabs_content.png b/apps/website/screens/components/tabs/usage/images/tabs_content.png deleted file mode 100644 index 3367469433..0000000000 Binary files a/apps/website/screens/components/tabs/usage/images/tabs_content.png and /dev/null differ diff --git a/apps/website/screens/components/tabs/usage/images/tabs_default_placement.png b/apps/website/screens/components/tabs/usage/images/tabs_default_placement.png deleted file mode 100644 index b928c994df..0000000000 Binary files a/apps/website/screens/components/tabs/usage/images/tabs_default_placement.png and /dev/null differ diff --git a/apps/website/screens/components/tabs/usage/images/tabs_mobile_navigation.png b/apps/website/screens/components/tabs/usage/images/tabs_mobile_navigation.png deleted file mode 100644 index 87af35f31a..0000000000 Binary files a/apps/website/screens/components/tabs/usage/images/tabs_mobile_navigation.png and /dev/null differ diff --git a/apps/website/screens/components/tabs/usage/images/tabs_panel_behavior.png b/apps/website/screens/components/tabs/usage/images/tabs_panel_behavior.png deleted file mode 100644 index 5edcec332c..0000000000 Binary files a/apps/website/screens/components/tabs/usage/images/tabs_panel_behavior.png and /dev/null differ diff --git a/apps/website/screens/components/tabs/usage/images/tabs_panel_navigation.png b/apps/website/screens/components/tabs/usage/images/tabs_panel_navigation.png deleted file mode 100644 index 654679a513..0000000000 Binary files a/apps/website/screens/components/tabs/usage/images/tabs_panel_navigation.png and /dev/null differ diff --git a/apps/website/screens/components/tabs/usage/images/tabs_placement.png b/apps/website/screens/components/tabs/usage/images/tabs_placement.png deleted file mode 100644 index 2a10dbb080..0000000000 Binary files a/apps/website/screens/components/tabs/usage/images/tabs_placement.png and /dev/null differ diff --git a/apps/website/screens/components/tabs/usage/images/tabs_scrollable_panel_behavior.png b/apps/website/screens/components/tabs/usage/images/tabs_scrollable_panel_behavior.png deleted file mode 100644 index db85f6aed0..0000000000 Binary files a/apps/website/screens/components/tabs/usage/images/tabs_scrollable_panel_behavior.png and /dev/null differ diff --git a/apps/website/screens/components/tabs/usage/images/tabs_scrollable_panel_navigation.png b/apps/website/screens/components/tabs/usage/images/tabs_scrollable_panel_navigation.png deleted file mode 100644 index 742470cc34..0000000000 Binary files a/apps/website/screens/components/tabs/usage/images/tabs_scrollable_panel_navigation.png and /dev/null differ diff --git a/apps/website/screens/components/tabs/usage/images/tabs_scrollable_placement.png b/apps/website/screens/components/tabs/usage/images/tabs_scrollable_placement.png deleted file mode 100644 index bc34d4a7e6..0000000000 Binary files a/apps/website/screens/components/tabs/usage/images/tabs_scrollable_placement.png and /dev/null differ diff --git a/apps/website/screens/components/tabs/usage/images/tabs_typography.png b/apps/website/screens/components/tabs/usage/images/tabs_typography.png deleted file mode 100644 index 2e7ae0e62f..0000000000 Binary files a/apps/website/screens/components/tabs/usage/images/tabs_typography.png and /dev/null differ diff --git a/apps/website/screens/components/tabs/usage/images/tabs_usage.png b/apps/website/screens/components/tabs/usage/images/tabs_usage.png deleted file mode 100644 index c2c8b3a2d7..0000000000 Binary files a/apps/website/screens/components/tabs/usage/images/tabs_usage.png and /dev/null differ diff --git a/packages/lib/src/tabs/Tab.tsx b/packages/lib/src/tabs/Tab.tsx index 2ddbff058e..ea8a87b8d3 100644 --- a/packages/lib/src/tabs/Tab.tsx +++ b/packages/lib/src/tabs/Tab.tsx @@ -6,52 +6,106 @@ import { Tooltip } from "../tooltip/Tooltip"; import TabsContext from "./TabsContext"; import { TabProps, TabsContextProps } from "./types"; +export const sharedTabStyles = ` + background-color: var(--color-bg-neutral-lightest); + color: var(--color-fg-neutral-stronger); + cursor: pointer; + + &[aria-selected="true"]:enabled { + color: var(--color-fg-primary-strong); + } + &:hover:enabled { + background: var(--color-bg-primary-lighter); + } + &:active:enabled { + background: var(--color-bg-primary-lighter); + } + &:focus:enabled { + outline: var(--border-width-m) var(--border-style-default) var(--border-color-secondary-medium); + outline-offset: -2px; + } + &:disabled { + color: var(--color-fg-neutral-medium); + cursor: not-allowed; + } +`; + +const Tab = styled.button<{ + hasLabelAndIcon: boolean; + iconPosition: TabsContextProps["iconPosition"]; +}>` + position: relative; + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-gap-m); + border: var(--border-width-none); + height: ${({ iconPosition }) => (iconPosition === "top" ? "71px" : "47px")}; + max-width: 360px; + min-width: max-content; + padding: ${({ iconPosition }) => (iconPosition === "top" ? "var(--spacing-padding-xs)" : "var(--spacing-gap-s)")} + var(--spacing-padding-m); + ${sharedTabStyles} +`; + +const LabelIconContainer = styled.div<{ + iconPosition: TabsContextProps["iconPosition"]; +}>` + display: flex; + flex-direction: ${({ iconPosition }) => (iconPosition === "top" ? "column" : "row")}; + align-items: center; + gap: var(--spacing-gap-m); +`; + +const Label = styled.span` + font-family: var(--typography-font-family); + font-size: var(--typography-label-l); + font-weight: var(--typography-label-semibold); +`; + +const IconContainer = styled.div` + display: flex; + font-size: var(--height-s); + svg { + height: var(--height-s); + width: 24px; + } +`; + +const BadgeContainer = styled.div<{ + hasLabelAndIcon: boolean; + iconPosition: TabsContextProps["iconPosition"]; +}>` + display: flex; + align-items: ${({ hasLabelAndIcon, iconPosition }) => + hasLabelAndIcon && iconPosition === "top" ? "flex-start" : "center"}; + height: 100%; +`; + +const Underline = styled.span<{ active: boolean }>` + position: absolute; + bottom: -1px; + left: 0; + width: 100%; + height: ${({ active }) => (active ? "var(--border-width-m)" : "var(--border-width-s)")}; + background-color: ${({ active }) => + active ? "var(--border-color-primary-stronger)" : "var(--border-color-neutral-medium)"}; +`; + const DxcTab = forwardRef( ( - { - icon, - label, - title, - disabled = false, - active, - notificationNumber = false, - onClick = () => {}, - onHover = () => {}, - }: TabProps, + { active, disabled, icon, label, notificationNumber, onClick, onHover, title }: TabProps, ref: Ref - ): JSX.Element => { - const tabRef = useRef(null); - + ) => { const { - iconPosition = "top", - tabIndex = 0, + activeLabel, focusedLabel, + iconPosition, isControlled, - activeLabel, - hasLabelAndIcon = false, setActiveLabel, - setActiveIndicatorWidth, - setActiveIndicatorLeft, + tabIndex = 0, } = useContext(TabsContext) ?? {}; - - useEffect(() => { - if (focusedLabel === label) { - tabRef?.current?.focus(); - } - }, [focusedLabel, label]); - - useEffect(() => { - if (activeLabel === label) { - setActiveIndicatorWidth?.(tabRef.current?.offsetWidth ?? 0); - setActiveIndicatorLeft?.(tabRef.current?.offsetLeft ?? 0); - } - }, [activeLabel, label, setActiveIndicatorWidth, setActiveIndicatorLeft]); - - useEffect(() => { - if (active) { - setActiveLabel?.(label); - } - }, [active, label, setActiveLabel]); + const tabRef = useRef(null); const handleOnKeyDown = (event: KeyboardEvent) => { switch (event.key) { @@ -65,200 +119,59 @@ const DxcTab = forwardRef( } }; + useEffect(() => { + if (focusedLabel === label) tabRef?.current?.focus(); + }, [focusedLabel, label]); + + useEffect(() => { + if (active) setActiveLabel?.(label); + }, [active, label, setActiveLabel]); + return ( - { + if (!isControlled) setActiveLabel?.(label); + onClick?.(); + }} + onKeyDown={handleOnKeyDown} + onMouseEnter={() => onHover?.()} ref={(anchorRef) => { tabRef.current = anchorRef; - if (ref) { - if (typeof ref === "function") { - ref(anchorRef); - } else { + if (typeof ref === "function") ref(anchorRef); + else { const currentRef = ref as MutableRefObject; currentRef.current = anchorRef; } } }} - onClick={() => { - if (!isControlled) { - setActiveLabel?.(label); - } - onClick(); - }} - onMouseEnter={() => onHover()} - onKeyDown={handleOnKeyDown} + role="tab" + tabIndex={activeLabel === label && !disabled ? tabIndex : -1} + type="button" > - - {icon && ( - - {typeof icon === "string" ? : icon} - - )} - - - {notificationNumber && !disabled && ( - + + {icon && {typeof icon === "string" ? : icon}} + + + {!disabled && notificationNumber && ( + )} - + + ); } ); -const TabContainer = styled.button<{ - hasLabelAndIcon: boolean; - iconPosition: TabsContextProps["iconPosition"]; -}>` - text-transform: ${(props) => props.theme.fontTextTransform}; - overflow: hidden; - flex-shrink: 0; - border: 0; - cursor: pointer; - display: inline-flex; - align-items: center; - user-select: none; - vertical-align: middle; - justify-content: center; - min-width: 90px; - max-width: 360px; - padding: ${(props) => - ((!props.hasLabelAndIcon || (props.hasLabelAndIcon && props.iconPosition !== "top")) && "12px 16px") || "8px 16px"}; - height: ${(props) => - ((!props.hasLabelAndIcon || (props.hasLabelAndIcon && props.iconPosition !== "top")) && "47px") || "71px"}; - min-height: ${(props) => - ((!props.hasLabelAndIcon || (props.hasLabelAndIcon && props.iconPosition !== "top")) && "47px") || "71px"}; - background-color: ${(props) => props.theme.unselectedBackgroundColor}; - - &:hover { - background-color: ${(props) => `${props.theme.hoverBackgroundColor} !important`}; - } - &:active { - background-color: ${(props) => `${props.theme.pressedBackgroundColor} !important`}; - } - &:focus { - outline: ${(props) => props.theme.focusOutline} solid 1px; - outline-offset: -1px; - } - - svg, - span:before { - color: ${(props) => props.theme.unselectedIconColor}; - } - - &[aria-selected="true"] { - background-color: ${(props) => props.theme.selectedBackgroundColor}; - svg, - span:before { - color: ${(props) => props.theme.selectedIconColor}; - } - opacity: 1; - } - - &:disabled { - background-color: ${(props) => props.theme.unselectedBackgroundColor} !important; - cursor: not-allowed !important; - pointer-events: all; - font-style: ${(props) => props.theme.disabledFontStyle}; - outline: none !important; - - svg, - span:before { - color: ${(props) => props.theme.disabledIconColor}; - } - > div { - opacity: 0.5; - } - } -`; - -const BadgeContainer = styled.div<{ - hasLabelAndIcon: boolean; - iconPosition: TabsContextProps["iconPosition"]; -}>` - margin-left: 12px; - height: 100%; - display: flex; - align-items: ${(props) => (props.hasLabelAndIcon && props.iconPosition === "top" ? "flex-start" : "center")}; - justify-content: flex-start; - flex-direction: column; -`; - -const MainLabelContainer = styled.div<{ - notificationNumber: TabProps["notificationNumber"]; - hasLabelAndIcon: boolean; - iconPosition: TabsContextProps["iconPosition"]; - disabled: boolean; -}>` - display: flex; - flex-direction: ${(props) => (props.hasLabelAndIcon && props.iconPosition === "top" && "column") || "row"}; - align-items: center; - margin-left: ${(props) => - props.notificationNumber && !props.disabled - ? typeof props.notificationNumber === "number" - ? "36px" - : "18px" - : "unset"}; -`; - -const Label = styled.span<{ - disabled: TabProps["disabled"]; - label: TabProps["label"]; - activeLabel?: string; -}>` - display: inline; - color: ${(props) => - props.disabled - ? props.theme.disabledFontColor - : props.activeLabel === props.label - ? props.theme.selectedFontColor - : props.theme.unselectedFontColor}; - font-family: ${(props) => props.theme.fontFamily}; - font-size: ${(props) => props.theme.fontSize}; - font-style: ${(props) => (props.disabled ? props.theme.disabledFontStyle : props.theme.fontStyle)}; - font-weight: ${(props) => props.theme.fontWeight}; - text-align: center; - letter-spacing: 0.025em; - line-height: 1.715em; - text-decoration: none; - text-overflow: unset; - white-space: normal; - margin: 0; -`; - -const TabIconContainer = styled.div<{ - hasLabelAndIcon: boolean; - iconPosition: TabsContextProps["iconPosition"]; -}>` - display: flex; - margin-bottom: ${(props) => (props.hasLabelAndIcon && props.iconPosition === "top" && "8px") || ""}; - margin-right: ${(props) => (props.hasLabelAndIcon && props.iconPosition === "left" && "12px") || ""}; - font-size: 22px; - - svg { - height: 22px; - width: 22px; - } -`; - export default DxcTab; diff --git a/packages/lib/src/tabs/Tabs.stories.tsx b/packages/lib/src/tabs/Tabs.stories.tsx index 9b622b17f2..1561c52e5e 100644 --- a/packages/lib/src/tabs/Tabs.stories.tsx +++ b/packages/lib/src/tabs/Tabs.stories.tsx @@ -1,10 +1,10 @@ import { INITIAL_VIEWPORTS } from "@storybook/addon-viewport"; import ExampleContainer from "../../.storybook/components/ExampleContainer"; import Title from "../../.storybook/components/Title"; -import { HalstackProvider } from "../HalstackContext"; import DxcTabs from "./Tabs"; -import type { Space } from "../common/utils"; +import type { Margin, Space } from "../common/utils"; import { Meta, StoryObj } from "@storybook/react/*"; +import { userEvent, within } from "@storybook/test"; export default { title: "Tabs", @@ -22,7 +22,7 @@ const iconSVG = ( ); -const tabs = (margin?: Space) => ( +const tabs = (margin?: Space | Margin) => ( <> @@ -62,20 +62,6 @@ const disabledTabs = ( ); -const disabledTabsFirstActive = ( - - - <> - - - <> - - - <> - - -); - const firstDisabledTabs = ( @@ -127,7 +113,7 @@ const tabsIcon = (iconPosition?: "top" | "left") => ( <> - + <> @@ -168,57 +154,51 @@ const tabsNotificationIcon = (iconPosition?: "top" | "left") => ( ); -const opinionatedTheme = { - tabs: { - baseColor: "#5f249f", - }, -}; - const Tabs = () => ( <> - - {tabs()} - </ExampleContainer> - <ExampleContainer> - <Title title="Disabled tabs" theme="light" level={4} /> - {disabledTabs} - </ExampleContainer> - <ExampleContainer> - <Title title="First two tabs disabled" theme="light" level={4} /> - {firstDisabledTabs} + <Title title="Default" theme="light" level={4} /> + {tabs({ bottom: "xxlarge" })} </ExampleContainer> <ExampleContainer pseudoState="pseudo-hover"> - <Title title="Hovered tabs" theme="light" level={4} /> + <Title title="Hovered" theme="light" level={4} /> {tabs()} </ExampleContainer> <ExampleContainer pseudoState="pseudo-focus"> - <Title title="Focused tabs" theme="light" level={4} /> + <Title title="Focused" theme="light" level={4} /> {tabs()} </ExampleContainer> <ExampleContainer pseudoState="pseudo-active"> - <Title title="Actived tabs" theme="light" level={4} /> + <Title title="Active" theme="light" level={4} /> {tabs()} </ExampleContainer> + <ExampleContainer> + <Title title="Disabled" theme="light" level={4} /> + {firstDisabledTabs} + </ExampleContainer> + <ExampleContainer> + <Title title="All tabs disabled" theme="light" level={4} /> + {disabledTabs} + </ExampleContainer> <ExampleContainer> <Title title="With notification number" theme="light" level={4} /> {tabsNotification()} </ExampleContainer> <ExampleContainer> - <Title title="With icon position top" theme="light" level={4} /> + <Title title="With icon position left" theme="light" level={4} /> {tabsIcon()} </ExampleContainer> <ExampleContainer> - <Title title="With icon position left" theme="light" level={4} /> - {tabsIcon("left")} + <Title title="With icon position top" theme="light" level={4} /> + {tabsIcon("top")} </ExampleContainer> <ExampleContainer> <Title title="With icon and notification number" theme="light" level={4} /> {tabsNotificationIcon()} </ExampleContainer> <ExampleContainer> - <Title title="With icon on the left and notification number" theme="light" level={4} /> - {tabsNotificationIcon("left")} + <Title title="With icon on top and notification number" theme="light" level={4} /> + {tabsNotificationIcon("top")} </ExampleContainer> <Title title="Margins" theme="light" level={2} /> <ExampleContainer> @@ -249,27 +229,6 @@ const Tabs = () => ( <Title title="Xxlarge margin" theme="light" level={4} /> {tabs("xxlarge")} </ExampleContainer> - <Title title="Opinionated theme" theme="light" level={2} /> - <ExampleContainer> - <Title title="With icon and notification" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}>{tabsNotificationIcon()}</HalstackProvider> - </ExampleContainer> - <ExampleContainer> - <Title title="Disabled" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}>{disabledTabsFirstActive}</HalstackProvider> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-hover"> - <Title title="Hovered" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}>{tabs()}</HalstackProvider> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-focus"> - <Title title="Focused" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}>{tabs()}</HalstackProvider> - </ExampleContainer> - <ExampleContainer pseudoState="pseudo-active"> - <Title title="Actived" theme="light" level={4} /> - <HalstackProvider theme={opinionatedTheme}>{tabs()}</HalstackProvider> - </ExampleContainer> </> ); @@ -298,6 +257,11 @@ type Story = StoryObj<typeof DxcTabs>; export const Chromatic: Story = { render: Tabs, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const tabs = canvas.getAllByRole("tab"); + if (tabs[0]) await userEvent.hover(tabs[0]); + }, }; export const ScrollableTabs: Story = { diff --git a/packages/lib/src/tabs/Tabs.test.tsx b/packages/lib/src/tabs/Tabs.test.tsx index 82362b39dd..7518e6f0eb 100644 --- a/packages/lib/src/tabs/Tabs.test.tsx +++ b/packages/lib/src/tabs/Tabs.test.tsx @@ -2,6 +2,12 @@ import "@testing-library/jest-dom"; import { fireEvent, render } from "@testing-library/react"; import DxcTabs from "./Tabs"; +(global as any).ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; + const sampleTabs = ( <DxcTabs> <DxcTabs.Tab label="Tab-1" notificationNumber={10} defaultActive> diff --git a/packages/lib/src/tabs/Tabs.tsx b/packages/lib/src/tabs/Tabs.tsx index 5375493e9c..43a56a694b 100644 --- a/packages/lib/src/tabs/Tabs.tsx +++ b/packages/lib/src/tabs/Tabs.tsx @@ -2,78 +2,110 @@ import { Children, isValidElement, KeyboardEvent, - MutableRefObject, ReactElement, - useCallback, useContext, - useEffect, + useLayoutEffect, useMemo, useRef, useState, } from "react"; -import styled, { ThemeProvider } from "styled-components"; +import styled from "styled-components"; import TabsContext from "./TabsContext"; -import DxcTab from "./Tab"; +import DxcTab, { sharedTabStyles } from "./Tab"; import TabsPropsType, { TabProps } from "./types"; import DxcTabsLegacy from "./TabsLegacy"; import { spaces } from "../common/variables"; -import HalstackContext, { HalstackLanguageContext } from "../HalstackContext"; +import { HalstackLanguageContext } from "../HalstackContext"; import DxcIcon from "../icon/Icon"; +import { getPreviousTabIndex, getNextTabIndex } from "./utils"; +import useWidth from "../utils/useWidth"; -const useResize = (refTabList: MutableRefObject<HTMLDivElement | null>) => { - const [viewWidth, setViewWidth] = useState(0); +const TabsContainer = styled.div<{ margin: TabsPropsType["margin"] }>` + position: relative; + margin: ${(props) => (props.margin && typeof props.margin !== "object" ? spaces[props.margin] : "0px")}; + margin-top: ${(props) => + props.margin && typeof props.margin === "object" && props.margin.top ? spaces[props.margin.top] : ""}; + margin-right: ${(props) => + props.margin && typeof props.margin === "object" && props.margin.right ? spaces[props.margin.right] : ""}; + margin-bottom: ${(props) => + props.margin && typeof props.margin === "object" && props.margin.bottom ? spaces[props.margin.bottom] : ""}; + margin-left: ${(props) => + props.margin && typeof props.margin === "object" && props.margin.left ? spaces[props.margin.left] : ""}; +`; - const handleWindowSizeChange = useCallback(() => { - setViewWidth(refTabList?.current?.offsetWidth ?? 0); - }, [refTabList]); +const Underline = styled.div` + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: var(--border-width-s); + background-color: var(--border-color-neutral-medium); +`; - useEffect(() => { - handleWindowSizeChange(); - window.addEventListener("resize", handleWindowSizeChange); - return () => { - window.removeEventListener("resize", handleWindowSizeChange); - }; - }, [handleWindowSizeChange]); +const Tabs = styled.div` + display: flex; + background-color: var(--color-bg-neutral-lightest); +`; - return viewWidth; -}; +const ScrollIndicatorButton = styled.button` + display: grid; + place-items: center; + background: var(--color-bg-neutral-lightest); + border: 0; + min-width: 47px; + height: 47px; + padding: 0; + ${sharedTabStyles} -const getPreviousTabIndex = (array: ReactElement<TabProps>[], initialIndex: number): number => { - let index = initialIndex === 0 ? array.length - 1 : initialIndex - 1; - while (array[index]?.props.disabled) { - index = index === 0 ? array.length - 1 : index - 1; + /* Scroll indicator arrow icon */ + > span { + display: flex; + font-size: var(--height-s); + svg { + height: var(--height-s); + width: 24px; + } } - return index; -}; +`; -const getNextTabIndex = (array: ReactElement<TabProps>[], initialIndex: number): number => { - let index = initialIndex === array.length - 1 ? 0 : initialIndex + 1; - while (array[index]?.props.disabled) { - index = index === array.length - 1 ? 0 : index + 1; +const TabsContent = styled.div` + flex: 1 1 auto; + display: inline-block; + position: relative; + white-space: nowrap; + overflow-x: auto; + ::-webkit-scrollbar { + display: none; } - return index; -}; +`; + +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 = ({ - defaultActiveTabIndex, activeTabIndex, - tabs, + children, + defaultActiveTabIndex, + iconPosition = "left", + margin, onTabClick, onTabHover, - margin, - iconPosition = "top", tabIndex = 0, - children, + tabs, }: TabsPropsType) => { const childrenArray: ReactElement<TabProps>[] = useMemo( () => Children.toArray(children) as ReactElement<TabProps>[], [children] ); - const hasLabelAndIcon = useMemo( - () => childrenArray.some((child) => isValidElement(child) && child.props.icon && child.props.label), - [childrenArray] - ); - const [activeTabLabel, setActiveTabLabel] = useState(() => { const hasActiveChild = childrenArray.some( (child) => isValidElement(child) && (child.props.active || child.props.defaultActive) && !child.props.disabled @@ -86,52 +118,36 @@ const DxcTabs = ({ return isValidElement(initialActiveTab) ? initialActiveTab.props.label : ""; }); - const [innerFocusIndex, setInnerFocusIndex] = useState<number | null>(null); - const [activeIndicatorWidth, setActiveIndicatorWidth] = useState(0); - const [activeIndicatorLeft, setActiveIndicatorLeft] = useState(0); const [countClick, setCountClick] = useState(0); - const [totalTabsWidth, setTotalTabsWidth] = useState(0); - const [translateScroll, setTranslateScroll] = useState(0); - const [scrollRightEnabled, setScrollRightEnabled] = useState(true); + const [innerFocusIndex, setInnerFocusIndex] = useState<number | null>(null); const [scrollLeftEnabled, setScrollLeftEnabled] = useState(false); - const [minHeightTabs, setMinHeightTabs] = useState(0); + const [scrollRightEnabled, setScrollRightEnabled] = useState(true); + const [translateScroll, setTranslateScroll] = useState(0); + const [totalTabsWidth, setTotalTabsWidth] = useState(0); const refTabList = useRef<HTMLDivElement | null>(null); - const colorsTheme = useContext(HalstackContext); - const viewWidth = useResize(refTabList); const translatedLabels = useContext(HalstackLanguageContext); - const enabledIndicator = useMemo(() => viewWidth < totalTabsWidth, [viewWidth]); - - useEffect(() => { - if (refTabList.current) { - setTotalTabsWidth((refTabList.current.firstElementChild as HTMLElement)?.offsetWidth); - setMinHeightTabs(refTabList.current.offsetHeight + 1); - } - }, []); - + const viewWidth = useWidth(refTabList.current); const contextValue = useMemo(() => { const focusedChild = innerFocusIndex != null ? childrenArray[innerFocusIndex] : null; return { - iconPosition, - tabIndex, + activeLabel: activeTabLabel, focusedLabel: isValidElement(focusedChild) ? focusedChild.props.label : "", + iconPosition, isControlled: childrenArray.some((child) => isValidElement(child) && typeof child.props.active !== "undefined"), - activeLabel: activeTabLabel, - hasLabelAndIcon, setActiveLabel: setActiveTabLabel, - setActiveIndicatorWidth, - setActiveIndicatorLeft, + tabIndex, }; - }, [iconPosition, tabIndex, innerFocusIndex, activeTabLabel, childrenArray, hasLabelAndIcon]); + }, [activeTabLabel, childrenArray, iconPosition, innerFocusIndex, tabIndex]); const scrollLeft = () => { - const scrollWidth = (refTabList?.current?.offsetHeight ?? 0) * 0.75; + const offsetHeight = refTabList?.current?.offsetHeight ?? 0; let moveX = 0; - if (countClick <= scrollWidth) { + if (countClick <= offsetHeight) { moveX = 0; setScrollLeftEnabled(false); setScrollRightEnabled(true); } else { - moveX = countClick - scrollWidth; + moveX = countClick - offsetHeight * 2; setScrollRightEnabled(true); setScrollLeftEnabled(true); } @@ -141,14 +157,13 @@ const DxcTabs = ({ const scrollRight = () => { const offsetHeight = refTabList?.current?.offsetHeight ?? 0; - const scrollWidth = offsetHeight * 0.75; let moveX = 0; - if (countClick + scrollWidth + offsetHeight >= totalTabsWidth) { + if (countClick + offsetHeight >= totalTabsWidth) { moveX = totalTabsWidth - offsetHeight; setScrollRightEnabled(false); setScrollLeftEnabled(true); } else { - moveX = countClick + scrollWidth; + moveX = countClick + offsetHeight * 2; setScrollLeftEnabled(true); setScrollRightEnabled(true); } @@ -179,195 +194,76 @@ const DxcTabs = ({ } }; + useLayoutEffect(() => { + if (refTabList.current) + setTotalTabsWidth(() => { + let total = 0; + refTabList.current?.querySelectorAll('[role="tab"]').forEach((tab) => { + total += (tab as HTMLElement).offsetWidth; + }); + return total; + }); + }, []); + return children ? ( <> - <ThemeProvider theme={colorsTheme.tabs}> - <TabsContainer margin={margin}> - <Underline /> - <Tabs hasLabelAndIcon={hasLabelAndIcon} iconPosition={iconPosition}> - <ScrollIndicator - onClick={scrollLeft} - enabled={enabledIndicator} - disabled={!scrollLeftEnabled} + <TabsContainer margin={margin}> + <Underline /> + <Tabs> + {viewWidth < totalTabsWidth && ( + <ScrollIndicatorButton aria-label={translatedLabels.tabs.scrollLeft} + disabled={!scrollLeftEnabled} + onClick={scrollLeft} tabIndex={scrollLeftEnabled ? tabIndex : -1} - minHeightTabs={minHeightTabs} > <DxcIcon icon="keyboard_arrow_left" /> - </ScrollIndicator> - <TabsContent> - <TabsContentScroll translateScroll={translateScroll} ref={refTabList} enabled={enabledIndicator}> - <TabList role="tablist" onKeyDown={handleOnKeyDown} minHeightTabs={minHeightTabs}> - <TabsContext.Provider value={contextValue}>{children}</TabsContext.Provider> - </TabList> - <ActiveIndicator - tabWidth={activeIndicatorWidth} - tabLeft={activeIndicatorLeft} - aria-disabled={childrenArray.some( - (child) => isValidElement(child) && activeTabLabel === child.props.label && child.props.disabled - )} - /> - </TabsContentScroll> - </TabsContent> - <ScrollIndicator - onClick={scrollRight} - enabled={enabledIndicator} - disabled={!scrollRightEnabled} + </ScrollIndicatorButton> + )} + <TabsContent> + <ScrollableTabsList + enabled={viewWidth < totalTabsWidth} + iconPosition={iconPosition} + onKeyDown={handleOnKeyDown} + ref={refTabList} + role="tablist" + translateScroll={translateScroll} + > + <TabsContext.Provider value={contextValue}>{children}</TabsContext.Provider> + </ScrollableTabsList> + </TabsContent> + {viewWidth < totalTabsWidth && ( + <ScrollIndicatorButton aria-label={translatedLabels.tabs.scrollRight} + disabled={!scrollRightEnabled} + onClick={scrollRight} tabIndex={scrollRightEnabled ? tabIndex : -1} - minHeightTabs={minHeightTabs} > <DxcIcon icon="keyboard_arrow_right" /> - </ScrollIndicator> - </Tabs> - </TabsContainer> - </ThemeProvider> - {Children.map(children, (child) => { - if (isValidElement(child) && child.props.label === activeTabLabel) { - return child.props.children; - } - return null; - })} + </ScrollIndicatorButton> + )} + </Tabs> + </TabsContainer> + {Children.map(children, (child) => + isValidElement(child) && child.props.label === activeTabLabel ? child.props.children : null + )} </> ) : ( tabs != null && ( <DxcTabsLegacy - defaultActiveTabIndex={defaultActiveTabIndex} activeTabIndex={activeTabIndex} - tabs={tabs} + defaultActiveTabIndex={defaultActiveTabIndex} + iconPosition={iconPosition} + margin={margin} onTabClick={onTabClick} onTabHover={onTabHover} - margin={margin} - iconPosition={iconPosition} tabIndex={tabIndex} + tabs={tabs} /> ) ); }; -const Underline = styled.div` - position: absolute; - left: 0; - bottom: 0; - width: 100%; - height: ${(props) => props.theme.dividerThickness}; - background-color: ${(props) => props.theme.dividerColor}; -`; - -const TabsContainer = styled.div<{ margin: TabsPropsType["margin"] }>` - position: relative; - margin: ${(props) => (props.margin && typeof props.margin !== "object" ? spaces[props.margin] : "0px")}; - margin-top: ${(props) => - props.margin && typeof props.margin === "object" && props.margin.top ? spaces[props.margin.top] : ""}; - margin-right: ${(props) => - props.margin && typeof props.margin === "object" && props.margin.right ? spaces[props.margin.right] : ""}; - margin-bottom: ${(props) => - props.margin && typeof props.margin === "object" && props.margin.bottom ? spaces[props.margin.bottom] : ""}; - margin-left: ${(props) => - props.margin && typeof props.margin === "object" && props.margin.left ? spaces[props.margin.left] : ""}; -`; - -const Tabs = styled.div<{ - hasLabelAndIcon: boolean; - iconPosition: TabsPropsType["iconPosition"]; -}>` - min-height: ${(props) => - ((!props.hasLabelAndIcon || (props.hasLabelAndIcon && props.iconPosition !== "top")) && "48px") || "72px"}; - height: ${(props) => - ((!props.hasLabelAndIcon || (props.hasLabelAndIcon && props.iconPosition !== "top")) && "48px") || "72px"}; - display: flex; - overflow: hidden; - background-color: ${(props) => props.theme.unselectedBackgroundColor}; -`; - -const ScrollIndicator = styled.button<{ - enabled: boolean; - minHeightTabs: number; -}>` - box-sizing: border-box; - display: ${(props) => (props.enabled ? "flex" : "none")}; - justify-content: center; - min-width: ${(props) => props.theme.scrollButtonsWidth}; - height: ${(props) => props.minHeightTabs - 1}px; - padding: 0; - border: none; - background-color: #ffffff; - font-size: 1.25rem; - cursor: pointer; - - &:hover { - background-color: ${(props) => `${props.theme.hoverBackgroundColor} !important`}; - } - &:focus { - outline: ${(props) => props.theme.focusOutline} solid 1px; - outline-offset: -1px; - } - &:active { - background-color: ${(props) => `${props.theme.pressedBackgroundColor} !important`}; - } - &:disabled { - cursor: default; - display: none; - svg { - visibility: hidden; - } - &:focus { - outline: none; - } - &:hover, - &:active { - background-color: transparent !important; - } - } - - span { - align-self: center; - height: 20px; - width: 20px; - } - - span::before { - color: ${(props) => props.theme.unselectedFontColor}; - } -`; - -const ActiveIndicator = styled.span<{ tabLeft: number; tabWidth: number }>` - position: absolute; - bottom: 0; - left: ${(props) => `${props.tabLeft}px`}; - width: ${(props) => `${props.tabWidth}px`}; - height: ${(props) => props.theme.selectedUnderlineThickness}; - background-color: ${(props) => props.theme.selectedUnderlineColor}; - &[aria-disabled="true"] { - background-color: ${(props) => props.theme.disabledFontColor}; - display: none; - } -`; - -const TabsContent = styled.div` - flex: 1 1 auto; - display: inline-block; - position: relative; - white-space: nowrap; - overflow-x: scroll; - ::-webkit-scrollbar { - display: none; - } -`; - -const TabList = styled.div<{ minHeightTabs: number }>` - display: flex; - min-height: ${(props) => props.minHeightTabs}px; -`; - -const TabsContentScroll = styled.div<{ - translateScroll: number; - enabled: boolean; -}>` - display: flex; - ${(props) => (props.enabled ? `transform: translateX(${props.translateScroll}px)` : `transform: translateX(0px)`)}; - transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; -`; - DxcTabs.Tab = DxcTab; + export default DxcTabs; diff --git a/packages/lib/src/tabs/types.ts b/packages/lib/src/tabs/types.ts index 96a41c9daf..2691000135 100644 --- a/packages/lib/src/tabs/types.ts +++ b/packages/lib/src/tabs/types.ts @@ -18,15 +18,12 @@ type TabCommonProps = { }; export type TabsContextProps = { - iconPosition: "top" | "left"; - tabIndex: number; + activeLabel: string; focusedLabel: string; + iconPosition?: "top" | "left"; isControlled: boolean; - activeLabel: string; - hasLabelAndIcon: boolean; setActiveLabel: (_tab: string) => void; - setActiveIndicatorWidth: (_width: number) => void; - setActiveIndicatorLeft: (_left: number) => void; + tabIndex: number; }; export type TabLabelProps = TabCommonProps & { @@ -77,41 +74,46 @@ export type TabProps = { type LegacyProps = { /** - * Initially active tab, only when it is uncontrolled. - */ - defaultActiveTabIndex?: number; - /** + * @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; /** - * An array of objects representing the tabs. + * @deprecated This prop is deprecated and will be removed in future versions. + * Initially active tab, only when it is uncontrolled. */ - tabs?: (TabLabelProps | TabIconProps)[]; + 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; - /** - * 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; /** * 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?: (TabLabelProps | TabIconProps)[]; }; type NewProps = { @@ -131,7 +133,6 @@ type NewProps = { /** * Contains one or more DxcTabs.Tab. */ - // children?: React.ReactElement<TabProps>[]; children?: ReactNode; }; diff --git a/packages/lib/src/tabs/utils.ts b/packages/lib/src/tabs/utils.ts new file mode 100644 index 0000000000..428da17890 --- /dev/null +++ b/packages/lib/src/tabs/utils.ts @@ -0,0 +1,18 @@ +import { ReactElement } from "react"; +import { TabProps } from "./types"; + +export const getNextTabIndex = (array: ReactElement<TabProps>[], initialIndex: number): number => { + let index = initialIndex === array.length - 1 ? 0 : initialIndex + 1; + while (array[index]?.props.disabled) { + index = index === array.length - 1 ? 0 : index + 1; + } + return index; +}; + +export const getPreviousTabIndex = (array: ReactElement<TabProps>[], initialIndex: number): number => { + let index = initialIndex === 0 ? array.length - 1 : initialIndex - 1; + while (array[index]?.props.disabled) { + index = index === 0 ? array.length - 1 : index - 1; + } + return index; +}; diff --git a/packages/lib/src/utils/useTimeout.tsx b/packages/lib/src/utils/useTimeout.ts similarity index 100% rename from packages/lib/src/utils/useTimeout.tsx rename to packages/lib/src/utils/useTimeout.ts diff --git a/packages/lib/src/utils/useWidth.tsx b/packages/lib/src/utils/useWidth.ts similarity index 84% rename from packages/lib/src/utils/useWidth.tsx rename to packages/lib/src/utils/useWidth.ts index 96171bc9aa..a17ff906fb 100644 --- a/packages/lib/src/utils/useWidth.tsx +++ b/packages/lib/src/utils/useWidth.ts @@ -1,5 +1,10 @@ import { useLayoutEffect, useState } from "react"; +/** + * Custom hook to get the width of an element and keep it updated when it changes. + * @param target + * @returns + */ const useWidth = <T extends Element>(target: T | null) => { const [width, setWidth] = useState(0);