Repository: mapbox/mr-ui
Pull request #238 (2025-05-08 11:27:47)

Restyle Tab
- Size only affects height and margin now. The font-size should be set by the app itself
- New prop tabBorder 
- Style of active, inactive, hovering components is controlled by *-on-acitve and *-on-hover classes


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0eb1403..e404746 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,8 @@
 ## Changelog
 
+## 2.18.0
+- [feature] Restyle Tab
+
 ## 2.17.1
 - [fix] Fixes clickOutsideCloses property in Popover component
 
diff --git a/package-lock.json b/package-lock.json
index 49ba3c4..cdb924e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "@mapbox/mr-ui",
-  "version": "2.17.1",
+  "version": "2.18.0",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "@mapbox/mr-ui",
-      "version": "2.17.1",
+      "version": "2.18.0",
       "license": "BSD-2-Clause",
       "dependencies": {
         "@mapbox/mbx-assembly": "^1.6.0",
diff --git a/package.json b/package.json
index 52ef4e0..d4a7ea6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@mapbox/mr-ui",
-  "version": "2.17.1",
+  "version": "2.18.0",
   "description": "UI components for Mapbox projects",
   "main": "index.js",
   "homepage": "./",
diff --git a/src/components/tabs/examples/tabs-example-a.tsx b/src/components/tabs/examples/tabs-example-a.tsx
index 53b2af8..cedabad 100644
--- a/src/components/tabs/examples/tabs-example-a.tsx
+++ b/src/components/tabs/examples/tabs-example-a.tsx
@@ -11,12 +11,18 @@ export default function Example(): ReactElement {
     <Tabs
       onChange={(id: string) => setActive(id)}
       active={active}
+      tabBorder={false}
       themeTabsContainer="mb12"
+      themeTabItem="color-gray color-gray-dark-on-active color-blue-on-hover pr12 txt-bold txt-s"
       items={[
-        { id: 'one', label: 'Tab 1 title', content: "Tab 1 content" },
-        { id: 'two', label: 'Tab 2 title', content: "Tab 2 content" },
-        { id: 'three', label: 'Tab 3 title', content: <div>Tab 3 content</div> },
-        { id: 'four', label: 'Tab 4 title', content: <div>Tab 4 content</div> },
+        { id: 'one', label: 'Tab 1 title', content: 'Tab 1 content' },
+        { id: 'two', label: 'Tab 2 title', content: 'Tab 2 content' },
+        {
+          id: 'three',
+          label: 'Tab 3 title',
+          content: <div>Tab 3 content</div>
+        },
+        { id: 'four', label: 'Tab 4 title', content: <div>Tab 4 content</div> }
       ]}
     />
   );
diff --git a/src/components/tabs/examples/tabs-example-b.tsx b/src/components/tabs/examples/tabs-example-b.tsx
index 461738a..9e38d63 100644
--- a/src/components/tabs/examples/tabs-example-b.tsx
+++ b/src/components/tabs/examples/tabs-example-b.tsx
@@ -37,19 +37,21 @@ const items = [
         <li>Chicken</li>
       </ul>
     )
-  },
+  }
 ];
 
 export default function Example(): ReactElement {
   const [active, setActive] = useState('bf');
 
   return (
-    <Tabs
-      items={items}
-      active={active}
-      onChange={(id: string) => setActive(id)}
-      overlapBorder={true}
-      themeTabsContainer={"mb12 border-b border--gray-light"}
-    />
+    <div>
+      <Tabs
+        items={items}
+        active={active}
+        onChange={(id: string) => setActive(id)}
+        themeTabItem="color-gray color-gray-dark-on-active color-blue-on-hover txt-bold txt-s"
+        themeTabsContainer={'mb12 border-b border--gray-light'}
+      />
+    </div>
   );
 }
diff --git a/src/components/tabs/examples/tabs-example-c.tsx b/src/components/tabs/examples/tabs-example-c.tsx
index 51d6a71..7d9cca2 100644
--- a/src/components/tabs/examples/tabs-example-c.tsx
+++ b/src/components/tabs/examples/tabs-example-c.tsx
@@ -25,15 +25,13 @@ export default function Example(): ReactElement {
   const [active, setActive] = useState('sizes');
 
   return (
-    <div className="px12 py12 bg-gray-faint">
+    <div className="px12 py12 bg-gray-faint txt-xs">
       <Tabs
         items={items}
         active={active}
         onChange={(id: string) => setActive(id)}
         size="small"
-        overlapBorder={true}
-        bold={false}
-        themeTabsContainer={"border-b border--gray-light w300 flex flex--end-main"}
+        themeTabsContainer={'border-b border--gray-light w300 flex flex--end-main'}
       />
     </div>
   );
diff --git a/src/components/tabs/examples/tabs-example-d.tsx b/src/components/tabs/examples/tabs-example-d.tsx
index a42a4de..f754ff4 100644
--- a/src/components/tabs/examples/tabs-example-d.tsx
+++ b/src/components/tabs/examples/tabs-example-d.tsx
@@ -1,26 +1,32 @@
 /*
-Large tabs with custom colors, a disabled item, and a container without a bottom border.
+Large tabs with custom style, a disabled item, a none string tab, and a container without a bottom border.
 */
 import React, { ReactElement, useState } from 'react';
 import Tabs from '../tabs';
+import Tooltip from '../../tooltip/tooltip';
 
 const items = [
   {
     id: 'Robots',
-    content: "Robots content"
+    content: 'Robots content'
   },
   {
     id: 'Animals',
     disabled: true,
-    content: "Animals content"
+    content: 'Animals content'
   },
   {
     id: 'Clouds',
-    content: "Clouds content"
+    content: 'Clouds content'
   },
   {
     id: 'Messes',
-    content: "Messes content"
+    label: (
+      <Tooltip content="Messes">
+        <span>Messes</span>
+      </Tooltip>
+    ),
+    content: 'Messes content'
   }
 ];
 
@@ -32,11 +38,10 @@ export default function Example(): ReactElement {
       items={items}
       active={active}
       onChange={(id: string) => setActive(id)}
+      tabBorder={false}
       size="large"
       themeTabsContainer="mb12"
-      activeColor="pink"
-      inactiveColor="purple"
-      hoverColor="purple-dark"
+      themeTabItem="color-pink-on-active color-purple bg-blue-faint-on-active color-purple-dark-on-hover px12 py6 round txt-bold"
     />
   );
 }
diff --git a/src/components/tabs/tab-item.tsx b/src/components/tabs/tab-item.tsx
index 5f17636..77788ec 100644
--- a/src/components/tabs/tab-item.tsx
+++ b/src/components/tabs/tab-item.tsx
@@ -1,86 +1,70 @@
 import React from 'react';
 import classnames from 'classnames';
-import {
-  SIZE_SMALL,
-  SIZE_MEDIUM,
-  SIZE_LARGE
-} from './tabs-constants';
 import capitalize from '../utils/capitalize';
 import { TabItemProps, SizeType } from './types';
+import { SIZE_SMALL, SIZE_LARGE } from './tabs-constants';
 
 interface Props extends TabItemProps {
-  inactiveColor: string,
-  activeColor: string,
-  hoverColor: string,
-  overlapBorder: boolean,
-  size: SizeType,
-  active?: boolean,
-  onClick?: (a: string, event: any) => void,
+  themeTabItem: string;
+  tabBorder: boolean;
+  size: SizeType;
+  active?: boolean;
+  onClick?: (a: string, event: any) => void;
 }
 
 export default function TabItem({
-  size = SIZE_MEDIUM,
   active = false,
   disabled = false,
-  inactiveColor,
-  activeColor,
-  hoverColor,
-  overlapBorder,
+  themeTabItem,
+  size,
+  tabBorder,
   label,
-  id,
+  id
 }: Props) {
-  // If we're overlapping a bottom border, each height must be 2px
-  // more than the perceived height to account for the negative
-  // margins on top and bottom.
+  const itemClassesArray = themeTabItem.split(' ');
+  // disabled tab should alway be gray, so we remove all color classes for it
+  const noneColorClasses = itemClassesArray.filter(
+    (c) => !c.startsWith('color-') && !c.startsWith('bg-')
+  );
+  const colorClasses = itemClassesArray
+    .filter((c) => c.startsWith('color-') || c.startsWith('bg-'))
+    .join(' ');
+
+  const itemClasses = classnames(`block relative`, noneColorClasses, {
+    [colorClasses]: !disabled,
+    'color-gray-light cursor-default': disabled,
+    'cursor-default': active,
+    'is-active': active && !disabled,
+    'border-b border--transparent border--gray-dark-on-active': tabBorder
+  });
+
+  label = label || capitalize(id);
+
   const getHeight = () => {
-    const offset = overlapBorder ? 2 : 0;
+    if (!tabBorder) {
+      return undefined;
+    }
     switch (size) {
       case SIZE_SMALL:
-        return 36 + offset;
+        return 36;
       case SIZE_LARGE:
-        return 48 + offset;
+        return 48;
       default:
-        return 42 + offset;
-    }
-  }
-
-  const renderBorder = () => {
-    if (!active) {
-      return null;
+        return 42;
     }
-    const borderClasses = classnames('absolute bottom left right border-b', {
-      'border-b--2': size !== SIZE_SMALL
-    });
-    return <span data-testid="tab-border" className={borderClasses} />;
-  }
-
-  const itemClasses = classnames(`block relative`, {
-    'mb-neg1 mt-neg1': overlapBorder,
-    [`color-${inactiveColor} color-${hoverColor}-on-hover`]:
-      !active && !disabled,
-    [`color-${activeColor} cursor-default`]: active,
-    'color-gray-light cursor-default': disabled
-  });
+  };
 
-  label = label || capitalize(id);
-  const content = (
+  return (
     <span
-      className="block relative flex flex--center-cross"
-      style={{ height: getHeight() }}
+      className={classnames(
+        'block relative flex flex--center-cross',
+        itemClasses
+      )}
+      style={{
+        height: getHeight()
+      }}
     >
       {label}
-      {renderBorder()}
-    </span>
-  );
-
-  const universalProps = {
-    className: itemClasses,
-  };
-
-  return (
-    <span data-testid="tab-text-wrapper" {...universalProps}>
-      {content}
     </span>
   );
 }
-
diff --git a/src/components/tabs/tabs.tsx b/src/components/tabs/tabs.tsx
index 46b6a3d..579af63 100644
--- a/src/components/tabs/tabs.tsx
+++ b/src/components/tabs/tabs.tsx
@@ -2,7 +2,7 @@ import React, { ReactElement } from 'react';
 import PropTypes from 'prop-types';
 import classnames from 'classnames';
 import TabItem from './tab-item';
-import { SIZE_SMALL, SIZE_MEDIUM } from './tabs-constants';
+import { SIZE_SMALL } from './tabs-constants';
 import * as RadixTabs from '@radix-ui/react-tabs';
 import { TabItemProps, SizeType } from './types';
 /**
@@ -15,55 +15,53 @@ interface Props {
   active: string;
   onChange?: (a: string) => void;
   size?: SizeType;
-  overlapBorder?: boolean;
-  inactiveColor?: string;
-  activeColor?: string;
-  hoverColor?: string,
-  bold?: boolean,
-  themeTabsContainer?: string,
+  tabBorder?: boolean;
+  themeTabsContainer?: string;
+  themeTabItem?: string;
+  id?: string;
 }
 
 export default function Tabs({
   size = 'medium',
-  inactiveColor = 'gray',
-  activeColor = 'gray-dark',
-  hoverColor = 'blue',
-  overlapBorder = false,
-  bold = true,
+  tabBorder = true,
   items,
   active,
   onChange,
-  themeTabsContainer = ""
+  themeTabItem = 'color-gray color-gray-dark-on-active color-blue-on-hover',
+  themeTabsContainer = '',
+  id
 }: Props): ReactElement {
   const small = size === SIZE_SMALL;
-  const medium = size === SIZE_MEDIUM;
-
-  const containerClasses = classnames(`flex txt-nowrap unselectable ${themeTabsContainer}`, {
-    'txt-bold': bold,
-    'txt-s': medium,
-    'txt-xs': small,
-  });
+  const containerClasses = classnames(
+    `flex txt-nowrap unselectable ${themeTabsContainer}`
+  );
 
   const itemEls = items.map((item, index) => {
     const first = index === 0;
     const layoutClasses = classnames({
-      ml12: !first && small,
-      'ml24 ml36-mxl': !first && !small,
+      ml12: !first && small && tabBorder,
+      'ml24 ml36-mxl': !first && !small && tabBorder,
       'inline-block': true
     });
     return (
-      <RadixTabs.Trigger aria-label={item.label} className={layoutClasses} data-testid={`button-tab-${item.id}`} value={item.id} key={item.id} disabled={item.disabled}>
+      <RadixTabs.Trigger
+        aria-label={typeof item.label === 'string' ? item.label : item.id}
+        className={layoutClasses}
+        data-testid={
+          id ? `button-tab-${id}-${item.id}` : `button-tab-${item.id}`
+        }
+        value={item.id}
+        key={item.id}
+        disabled={item.disabled}
+      >
         <TabItem
           active={active === item.id}
           size={size}
-          inactiveColor={inactiveColor}
-          activeColor={activeColor}
-          hoverColor={hoverColor}
-          overlapBorder={overlapBorder}
+          themeTabItem={themeTabItem}
+          tabBorder={tabBorder}
           {...item}
         />
       </RadixTabs.Trigger>
-
     );
   });
 
@@ -76,15 +74,21 @@ export default function Tabs({
   });
 
   return (
-    <RadixTabs.Root onValueChange={onChange} value={active}>
-      <div className={containerClasses} data-testid="tabs-wrapper">
-        <RadixTabs.List>
+    <RadixTabs.Root onValueChange={onChange} value={active} id={id}>
+      <div
+        className={containerClasses}
+        data-testid={id ? `tabs-wrapper-${id}` : 'tabs-wrapper'}
+      >
+        <RadixTabs.List
+          className={classnames({
+            'mb-neg1': tabBorder
+          })}
+        >
           {itemEls}
         </RadixTabs.List>
       </div>
       {tabContents}
     </RadixTabs.Root>
-
   );
 }
 
@@ -97,15 +101,15 @@ Tabs.propTypes = {
   /**
    * Each tab is an object with the following properties:
    * - `id` (required): A string ID.
-   * - `label`: Text. A button will be automatically created based on the label. if label is not provided the id will serve as a label
+   * - `label`: Text or React node. A button will be automatically created based on the label. if label is not provided the id will serve as a label
    * - `disabled`: Boolean.
-   * - `content`: React node for generating the tab content. 
+   * - `content`: React node for generating the tab content.
    *    If provided, the tab content will automatically be rendered when changing tabs. Tab content does not have any styling applied by by default
    */
   items: PropTypes.arrayOf(
     PropTypes.shape({
       id: PropTypes.string.isRequired,
-      label: PropTypes.string,
+      label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
       disabled: PropTypes.bool,
       content: PropTypes.node
     })
@@ -120,20 +124,15 @@ Tabs.propTypes = {
    */
   size: PropTypes.oneOf(['small', 'medium', 'large']),
   /**
-   * If `true`, the element will extend down one pixel so the underline beneath
-   * the active item overlaps the bottom border of a container.
-   * **You must provide your own bottom border,** by setting it on a container
-   * element.
+   * If `true`, there will be a bottom border on the active tab border. 
+   * The element will extend down one pixel so the underline beneath the active item overlaps the bottom border of a container.
+   * You must provide your own bottom border, by setting it on a container element or in the themeTabsContainer.
+   */
+  tabBorder: PropTypes.bool,
+  /** Css classes for wrapping the tabs. */
+  themeTabsContainer: PropTypes.string,
+  /** Css classes for the tab item. Active tab has the is-active class, so you can use *-on-active to
+   * control the color of the active tab.
    */
-  overlapBorder: PropTypes.bool,
-  /** The Assembly color of inactive items. */
-  inactiveColor: PropTypes.string,
-  /** The Assembly color of active items. */
-  activeColor: PropTypes.string,
-  /** The Assembly color of hovered inactive items. */
-  hoverColor: PropTypes.string,
-  /** Whether or not the text is bold. */
-  bold: PropTypes.bool,
-  /** Css classes for wrapping the tabs */
-  themeTabsContainer: PropTypes.string
+  themeTabItem: PropTypes.string
 };
diff --git a/src/components/tabs/types.ts b/src/components/tabs/types.ts
index 248131f..e892ce7 100644
--- a/src/components/tabs/types.ts
+++ b/src/components/tabs/types.ts
@@ -7,7 +7,7 @@ import {
 
 export interface TabItemProps {
   id: string,
-  label?: string,
+  label?: string | ReactNode,
   content?: ReactNode,
   disabled?: boolean,
 }


Test patch:
diff --git a/src/components/tabs/tabs.test.tsx b/src/components/tabs/tabs.test.tsx
index 07eb043..e3df3b9 100644
--- a/src/components/tabs/tabs.test.tsx
+++ b/src/components/tabs/tabs.test.tsx
@@ -1,6 +1,7 @@
 import React from 'react';
 import { render, screen, waitFor, within } from '@testing-library/react';
 import TabsWithContent from './examples/tabs-example-a';
+import TabsWithBorder from './examples/tabs-example-b';
 import TabsWithDisabledAndColors from './examples/tabs-example-d';
 import TabsWithThemeTabsContainerAndSize from './examples/tabs-example-c';
 
@@ -24,7 +25,6 @@ describe('Tabs', () => {
         await waitFor(() => {
             expect(screen.getByText('Tab 1 content')).toBeInTheDocument();
         })
-        expect(within(tab1).getByTestId('tab-border')).toHaveClass('border-b--2');
 
         const tab3 = screen.getByTestId('button-tab-three');
         tab3.focus();
@@ -33,7 +33,6 @@ describe('Tabs', () => {
         await waitFor(() => {
             expect(within(tab1).queryByTestId('tab-border')).not.toBeInTheDocument();
         })
-        expect(within(tab3).getByTestId('tab-border')).toHaveClass('border-b--2');
         expect(screen.getByText('Tab 3 content')).toBeInTheDocument();
         expect(screen.queryByText('Tab 1 content')).not.toBeInTheDocument();
     });
@@ -72,17 +71,16 @@ describe('Tabs', () => {
 
     test('adds color classes', () => {
         render(<TabsWithDisabledAndColors />);
-        expect(within(screen.getByTestId('button-tab-Robots')).getByTestId('tab-text-wrapper')).toHaveClass('color-purple');
-        expect(within(screen.getByTestId('button-tab-Robots')).getByTestId('tab-text-wrapper')).toHaveClass('color-purple-dark-on-hover');
-        expect(within(screen.getByTestId('button-tab-Clouds')).getByTestId('tab-text-wrapper')).toHaveClass('color-pink');
-        expect(within(screen.getByTestId('button-tab-Animals')).getByTestId('tab-text-wrapper')).toHaveClass('color-gray-light');
-    });
-
-    test('adds bold text class', () => {
-        render(<TabsWithDisabledAndColors />);
-        expect(screen.getByTestId('tabs-wrapper')).toHaveClass('txt-bold');
+        expect(within(screen.getByTestId('button-tab-Robots')).getByText('Robots')).toHaveClass('color-purple color-purple-dark-on-hover');
+        expect(within(screen.getByTestId('button-tab-Clouds')).getByText('Clouds')).toHaveClass('color-pink-on-active is-active');
+        expect(within(screen.getByTestId('button-tab-Animals')).getByText('Animals')).toHaveClass('color-gray-light');
     });
 
+    test('render border', () => {
+        render(<TabsWithBorder />);
+        expect(screen.getByTestId('tabs-wrapper')).toHaveClass('border-b border--gray-light mb-neg1');
+        expect(screen.getByText('Breakfast')).toHaveClass('border-b is-active');
+    })
 });