Repository: Sage/carbon (ts)

Issue:
Validations doesn't allow custom component wrappers in ButtonBar
### Current behaviour

If I render a `Button` (or more buttons) in a `ButtonBar` using an intermediate wrapper component, then the library throws an  error:

```
Invariant Violation
ButtonBar accepts only `Button` or `IconButton` elements.
```

### Expected behaviour

When I render a `Button` component in a `ButtonBar` using an intermediate wrapper component, it should render just fine. 

### CodeSandbox or Storybook URL

https://codesandbox.io/s/elegant-grothendieck-j1u89d

### JIRA Ticket (Sage Only)

_No response_

### Suggested Solution

Remove this validation from the application as it doesn't add much value, or turn it into a warning.

### Carbon Version

111.3.0

### Design Tokens Version

irrelevant

### What browsers are you seeing the problem on?

Chrome

### What Operating System are you seeing the problem on?

MacOS

### Anything else we should know?

It is implemented in src/components/button-bar/button-bar.component.tsx:28

### Confidentiality

- [X] I confirm there is no confidential or commercially sensitive information included.

Pull request:
fix(button-bar, button, icon-button): remove invariant and implement context to pass props
fix #5669

### Proposed behaviour

<!--
A clear and concise description of what changes this PR makes.

If applicable, add screenshots of a codesandbox to help explain your request. You can paste these directly into GitHub.

Please DO NOT share screenshots or the source code of your project.

You can create a codesandbox to show the behaviour before/after this pull request by forking this template https://codesandbox.io/s/carbon-quickstart-j5pb2

If you include a CodeSandbox link, the bot will fork it with the new built version of carbon.
If you have a commit that includes fixes #XXXX and issue #XXXX has a CodeSandbox link in the body, the bot will fork
it with the new built version of carbon.
-->

This change will remove the use of an Invariant and replace element.type with a context based approach for passing props from `ButtonBar` to its children `IconButton` and `Button`. This will mean consumers can use wrapper components within `ButtonBar` without receiving an Invariant Violation, and all props and `ref`s will be passed and preserved correctly. 

### Current behaviour

<!--
A clear and concise description of the behaviour before this change.

If applicable, add screenshots. You can paste these directly into GitHub.
-->

Currently, an Invariant is used to ensure children of `ButtonBar` are `Button` or `IconButton` elements, therefore wrapped components which return these elements cannot be used. Consequently, consumers cannot attach added logic to these components with the use of a wrapper, or have to use other complex approaches to avoid the Invariant violation and achieve functionality the or logic they desire.

### Checklist

<!-- Each PR should include the following -->

- [x] Commits follow our style guide
- [x] Related issues linked in commit messages if required
- [ ] Screenshots are included in the PR if useful
- [x] All themes are supported if required
- [x] Unit tests added or updated if required
- [x] Cypress automation tests added or updated if required
- [ ] Storybook added or updated if required
- [ ] Translations added or updated (including creating or amending translation keys table in storybook) if required
- [ ] Typescript `d.ts` file added or updated if required
- [ ] Related docs have been updated if required

#### QA

- [ ] Tested in CodeSandbox/storybook
- [ ] Add new Cypress test coverage if required
- [ ] Carbon implementation matches Design System/designs
- [ ] UI Tests GitHub check reviewed if required

### Additional context

<!-- Add any other context or links about the pull request here. -->

### Testing instructions

<!-- How can a reviewer test this PR? -->

[This CodeSandbox](https://codesandbox.io/s/beautiful-ride-wzkh3e?file=/src/index.js) is an example of the broken behaviour.

You can see the new behaviour by looking at the version in the comment by `codesandbox[bot]`.

<!-- Add CodeSandbox here -->


Patch:
diff --git a/src/components/button-bar/button-bar.component.tsx b/src/components/button-bar/button-bar.component.tsx
index 278b7512e..4d4170dea 100644
--- a/src/components/button-bar/button-bar.component.tsx
+++ b/src/components/button-bar/button-bar.component.tsx
@@ -1,71 +1,39 @@
-import React, { useMemo } from "react";
-import invariant from "invariant";
+import React from "react";
 import { SpaceProps } from "styled-system";
-
 import StyledButtonBar from "./button-bar.style";
-import Button from "../button";
-import IconButton from "../icon-button";
 
-export interface ButtonBarProps extends SpaceProps {
-  /** Button or IconButton Elements, to be rendered inside the component */
-  children: React.ReactNode;
+export interface ButtonBarContextProps {
   /** Apply fullWidth style to the button bar */
   fullWidth?: boolean;
   /** Defines an Icon position for buttons: "before" | "after" */
   iconPosition?: "before" | "after";
   /** Assigns a size to the buttons: "small" | "medium" | "large" */
   size?: "small" | "medium" | "large";
+  /** Color variants for new business themes: "primary" | "secondary" | "tertiary" | "darkBackground" */
+  buttonType?: "primary" | "secondary" | "primary";
+}
+export interface ButtonBarProps extends ButtonBarContextProps, SpaceProps {
+  /** Button or IconButton Elements, to be rendered inside the component */
+  children: React.ReactNode;
 }
 
+export const ButtonBarContext = React.createContext<ButtonBarContextProps>({});
+
 export const ButtonBar = ({
   children,
   size = "medium",
   iconPosition = "before",
   fullWidth = false,
   ...rest
-}: ButtonBarProps) => {
-  const hasProperChildren = useMemo(() => {
-    const incorrectChild = React.Children.toArray(children).find(
-      (child: React.ReactNode) => {
-        if (!React.isValidElement(child)) {
-          return true;
-        }
-
-        return child.type !== Button && child.type !== IconButton;
-      }
-    );
-
-    return !incorrectChild;
-  }, [children]);
-
-  invariant(
-    hasProperChildren,
-    "ButtonBar accepts only `Button` or `IconButton` elements."
-  );
-
-  const getBtnProps = (child: React.ReactElement) => {
-    const btnProps = {
-      ...child.props,
-      buttonType: "secondary",
-      size,
-      iconPosition,
-      fullWidth,
-      "aria-label":
-        child.type === IconButton
-          ? child.props?.ariaLabel || child.props?.children?.props?.type
-          : "",
-    };
-    return btnProps;
-  };
-
-  return (
-    <StyledButtonBar {...rest} fullWidth={fullWidth} size={size}>
-      {React.Children.map(children as React.ReactElement[], (child) => (
-        <child.type {...getBtnProps(child)} />
-      ))}
-    </StyledButtonBar>
-  );
-};
+}: ButtonBarProps) => (
+  <StyledButtonBar {...rest} fullWidth={fullWidth} size={size}>
+    <ButtonBarContext.Provider
+      value={{ buttonType: "secondary", size, iconPosition, fullWidth }}
+    >
+      {children}
+    </ButtonBarContext.Provider>
+  </StyledButtonBar>
+);
 
 ButtonBar.displayName = "ButtonBar";
 
diff --git a/src/components/button-bar/button-bar.spec.tsx b/src/components/button-bar/button-bar.spec.tsx
index 54def1a46..a40b72d97 100644
--- a/src/components/button-bar/button-bar.spec.tsx
+++ b/src/components/button-bar/button-bar.spec.tsx
@@ -7,6 +7,8 @@ import Button from "../button";
 import ButtonBar from "./button-bar.component";
 import { assertStyleMatch } from "../../__spec_helper__/test-utils";
 import IconButton from "../icon-button";
+import StyledButton from "../button/button.style";
+import StyledIconButton from "../icon-button/icon-button.style";
 
 const renderButtonBar = (
   text?: string,
@@ -131,7 +133,7 @@ describe("Button Bar", () => {
         wrapper.find(Icon).exists() &&
         wrapper.find(Icon).props().type === "filter";
       expect(assertion).toEqual(true);
-      const button = wrapper.find(Button);
+      const button = wrapper.find(StyledButton);
       expect(button.props().iconPosition).toEqual("after");
     });
   });
@@ -155,96 +157,126 @@ describe("Button Bar", () => {
       const wrapper = renderButtonWithIconBar(["csv", "pdf"]);
       expect(wrapper.find(Icon)).toHaveLength(2);
     });
-  });
-
-  describe("with children of wrong type", () => {
-    const errorMessage =
-      "ButtonBar accepts only `Button` or `IconButton` elements.";
-    it("throws an error if child is wrong element", () => {
-      const mockGlobal = jest
-        .spyOn(global.console, "error")
-        .mockImplementation(() => undefined);
-
-      expect(() => {
-        mount(
-          <ButtonBar>
-            <div>Div</div>
-          </ButtonBar>
-        );
-      }).toThrow(errorMessage);
-
-      mockGlobal.mockReset();
-    });
 
-    it("throws an error if child is not an element", () => {
-      const mockGlobal = jest
-        .spyOn(global.console, "error")
-        .mockImplementation(() => undefined);
-
-      expect(() => {
-        mount(<ButtonBar>text</ButtonBar>);
-      }).toThrow(errorMessage);
-
-      mockGlobal.mockReset();
-    });
-  });
-
-  describe("with different button props", () => {
-    it("Button types on button bar are always of type 'secondary'", () => {
+    it("when in a ButtonBar, and no aria-label is passed the aria-label is the Icon type", () => {
       const wrapper = mount(
         <ButtonBar>
-          <Button buttonType="primary">primary</Button>
-          <Button buttonType="tertiary" fullWidth>
-            tertiary
-          </Button>
+          <Button>bar</Button>
+          <IconButton onAction={() => undefined}>
+            <Icon type="csv" />
+          </IconButton>
         </ButtonBar>
       );
-      wrapper
-        .find(Button)
-        .forEach((button) =>
-          expect(button.props().buttonType).toEqual("secondary")
-        );
-    });
 
-    it("Buttons cannot be 'fullWidth'", () => {
-      const wrapper = renderButtonBar("Button", 1, {}, { fullWidth: true });
-      const buttons = wrapper.find(Button);
-      buttons.forEach((button) => expect(button.props().fullWidth).toBeFalsy());
+      const iconButton = wrapper.find(StyledIconButton);
+      expect(iconButton.prop("aria-label")).toEqual("csv");
     });
 
-    it("Size is always defined by ButtonBar, not the Buttons", () => {
+    it("when in a ButtonBar and a custom aria-label is passed the aria-label matches", () => {
       const wrapper = mount(
-        <ButtonBar size="small">
-          <Button size="small">Small</Button>
-          <Button size="medium">Medium</Button>
-          <Button size="large">Large</Button>
+        <ButtonBar>
+          <Button>bar</Button>
+          <IconButton aria-label="foobar" onAction={() => undefined}>
+            <Icon type="csv" />
+          </IconButton>
         </ButtonBar>
       );
-      wrapper
-        .find("Button")
-        .forEach((button) => expect(button.props().size).toEqual("small"));
-    });
 
-    it("Disabled button works correctly", () => {
-      const wrapper = renderButtonBar("Small", 1, {}, { disabled: true });
-      expect(wrapper.find("Button").props().disabled).toBeTruthy();
+      const iconButton = wrapper.find(StyledIconButton);
+      expect(iconButton.prop("aria-label")).toEqual("foobar");
     });
+  });
 
-    it("Destructive button works correctly", () => {
-      const wrapper = renderButtonBar("Small", 1, {}, { destructive: true });
-      expect(wrapper.find(Button).props().destructive).toBeTruthy();
-    });
+  describe("when custom Button wrapper components are used as children", () => {
+    it.each([
+      {
+        fullWidth: true,
+        size: "small",
+        iconPosition: "before",
+      },
+    ] as const)(
+      "all props are passed to Button children correctly using context",
+      (buttonBarProps) => {
+        const WrappedComponent = () => {
+          return (
+            <>
+              <Button>bar</Button>
+              <Button>bar</Button>
+            </>
+          );
+        };
 
-    it("Subtext works correctly", () => {
-      const wrapper = renderButtonBar(
-        "Sub",
-        1,
-        { size: "large" },
-        { subtext: "subtext" }
+        const wrapper = mount(
+          <ButtonBar {...buttonBarProps}>
+            <WrappedComponent />
+            <IconButton onAction={() => undefined}>
+              <Icon type="csv" />
+            </IconButton>
+          </ButtonBar>
+        );
+
+        const button = wrapper.find(StyledButton).at(0);
+        expect(button.props().fullWidth).toEqual(true);
+        expect(button.props().size).toEqual("small");
+        expect(button.props().iconPosition).toEqual("before");
+      }
+    );
+  });
+});
+
+describe("with different button props", () => {
+  it("Button types on button bar are always of type 'secondary'", () => {
+    const wrapper = mount(
+      <ButtonBar>
+        <Button buttonType="primary">primary</Button>
+        <Button buttonType="tertiary" fullWidth>
+          tertiary
+        </Button>
+      </ButtonBar>
+    );
+    wrapper
+      .find(StyledButton)
+      .forEach((button) =>
+        expect(button.props().buttonType).toEqual("secondary")
       );
-      expect(
-        wrapper.containsMatchingElement(<span>subtext</span>)
-      ).toBeTruthy();
-    });
+  });
+
+  it("fullWidth prop is passed to children'", () => {
+    const wrapper = renderButtonBar("Button", 1, {}, { fullWidth: true });
+    const buttons = wrapper.find(StyledButton);
+    buttons.forEach((button) => expect(button.props().fullWidth).toBe(true));
+  });
+
+  it("Size is always defined by ButtonBar, not the Buttons", () => {
+    const wrapper = mount(
+      <ButtonBar size="small">
+        <Button size="small">Small</Button>
+        <Button size="medium">Medium</Button>
+        <Button size="large">Large</Button>
+      </ButtonBar>
+    );
+    wrapper
+      .find(StyledButton)
+      .forEach((button) => expect(button.props().size).toEqual("small"));
+  });
+
+  it("Disabled button works correctly", () => {
+    const wrapper = renderButtonBar("Small", 1, {}, { disabled: true });
+    expect(wrapper.find("Button").props().disabled).toBeTruthy();
+  });
+
+  it("Destructive button works correctly", () => {
+    const wrapper = renderButtonBar("Small", 1, {}, { destructive: true });
+    expect(wrapper.find(Button).props().destructive).toBeTruthy();
+  });
+
+  it("Subtext works correctly", () => {
+    const wrapper = renderButtonBar(
+      "Sub",
+      1,
+      { size: "large" },
+      { subtext: "subtext" }
+    );
+    expect(wrapper.containsMatchingElement(<span>subtext</span>)).toBeTruthy();
   });
 });
diff --git a/src/components/button/button.component.tsx b/src/components/button/button.component.tsx
index ebfc3546c..da8a22e90 100644
--- a/src/components/button/button.component.tsx
+++ b/src/components/button/button.component.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useState } from "react";
+import React, { useCallback, useState, useContext } from "react";
 import { SpaceProps } from "styled-system";
 import invariant from "invariant";
 
@@ -8,6 +8,7 @@ import tagComponent from "../../__internal__/utils/helpers/tags/tags";
 import { TooltipProvider } from "../../__internal__/tooltip-provider";
 import Logger from "../../__internal__/utils/logger";
 import { TooltipPositions } from "../tooltip/tooltip.config";
+import { ButtonBarContext } from "../button-bar/button-bar.component";
 
 export type ButtonTypes =
   | "primary"
@@ -170,7 +171,7 @@ let deprecatedDashedButtonWarnTriggered = false;
 const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
   (
     {
-      size = "medium",
+      size: sizeProp = "medium",
       subtext = "",
       children,
       forwardRef,
@@ -179,7 +180,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
       destructive = false,
       buttonType: buttonTypeProp = "secondary",
       iconType,
-      iconPosition = "before",
+      iconPosition: iconPositionProp = "before",
       href,
       m = 0,
       px,
@@ -188,11 +189,23 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
       rel,
       iconTooltipMessage,
       iconTooltipPosition,
-      fullWidth = false,
+      fullWidth: fullWidthProp = false,
       ...rest
     }: ButtonProps,
     ref
   ) => {
+    const {
+      buttonType: buttonTypeContext,
+      size: sizeContext,
+      iconPosition: iconPositionContext,
+      fullWidth: fullWidthContext,
+    } = useContext(ButtonBarContext);
+
+    const buttonType = buttonTypeContext || buttonTypeProp;
+    const size = sizeContext || sizeProp;
+    const iconPosition = iconPositionContext || iconPositionProp;
+    const fullWidth = fullWidthContext || fullWidthProp;
+
     invariant(
       !!(children || iconType),
       "Either prop `iconType` must be defined or this node must have children."
@@ -211,8 +224,6 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
       );
     }
 
-    const buttonType = buttonTypeProp;
-
     if (!deprecatedDashedButtonWarnTriggered && buttonType === "dashed") {
       deprecatedDashedButtonWarnTriggered = true;
       Logger.deprecate(
diff --git a/src/components/icon-button/icon-button.component.tsx b/src/components/icon-button/icon-button.component.tsx
index 1828f6acf..aa83d4dff 100644
--- a/src/components/icon-button/icon-button.component.tsx
+++ b/src/components/icon-button/icon-button.component.tsx
@@ -1,10 +1,11 @@
-import React, { useState, useCallback } from "react";
+import React, { useState, useCallback, useContext } from "react";
 import { SpaceProps } from "styled-system";
 
 import Events from "../../__internal__/utils/helpers/events";
 import StyledIconButton from "./icon-button.style";
 import { IconProps } from "../icon";
 import { TooltipProvider } from "../../__internal__/tooltip-provider";
+import { ButtonBarContext } from "../button-bar/button-bar.component";
 
 export interface IconButtonProps extends SpaceProps {
   /** Prop to specify the aria-label of the icon-button component */
@@ -42,6 +43,15 @@ const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
   ) => {
     const [internalRef, setInternalRef] = useState<HTMLButtonElement>();
 
+    const context = useContext(ButtonBarContext);
+    const ariaLabelValue = Object.keys(context).length
+      ? ariaLabel ||
+        (internalRef?.querySelector(
+          "[data-component='icon']"
+        ) as Element)?.getAttribute("type") ||
+        undefined
+      : ariaLabel;
+
     const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
       if (Events.isEnterKey(e) || Events.isSpaceKey(e)) {
         e.preventDefault();
@@ -69,7 +79,7 @@ const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
       <StyledIconButton
         p={0}
         {...rest}
-        aria-label={ariaLabel}
+        aria-label={ariaLabelValue}
         onKeyDown={handleKeyDown}
         onClick={handleOnClick}
         ref={setRefs}


Test patch:
diff --git a/src/components/button-bar/button-bar-test.stories.tsx b/src/components/button-bar/button-bar-test.stories.tsx
index 1e719be39..b14c545e1 100644
--- a/src/components/button-bar/button-bar-test.stories.tsx
+++ b/src/components/button-bar/button-bar-test.stories.tsx
@@ -34,6 +34,27 @@ const commonArgsButtonBar = {
   iconPosition: "before",
 };
 
+export const DefaultWithWrapper = ({ ...args }) => {
+  const WrappedComponent = () => {
+    return (
+      <>
+        <Button iconType="bin">bar</Button>
+        <Button iconType="csv">bar</Button>
+        <Button iconType="pdf">bar</Button>
+      </>
+    );
+  };
+
+  return (
+    <ButtonBar {...args}>
+      <WrappedComponent />
+      <IconButton onAction={() => undefined}>
+        <Icon type="csv" />
+      </IconButton>
+    </ButtonBar>
+  );
+};
+
 export const Default = ({ ...args }) => (
   <ButtonBar {...args}>
     <Button iconType="search">Example Button</Button>
diff --git a/src/components/button-bar/button-bar.test.js b/src/components/button-bar/button-bar.test.js
index d126abe89..d92ac4985 100644
--- a/src/components/button-bar/button-bar.test.js
+++ b/src/components/button-bar/button-bar.test.js
@@ -1,12 +1,14 @@
 import React from "react";
-import { Default as ButtonBarCustom } from "./button-bar-test.stories";
+import {
+  Default as ButtonBarCustom,
+  DefaultWithWrapper as ButtonBarWithWrapper,
+} from "./button-bar-test.stories";
 import {
   BUTTON_BAR_SIZES,
   BUTTON_BAR_ICON_POSITIONS,
 } from "./button-bar.config";
 
 import { buttonDataComponent } from "../../../cypress/locators/button";
-
 import { icon } from "../../../cypress/locators";
 import { useJQueryCssValueAndAssert } from "../../../cypress/support/component-helper/common-steps";
 import CypressMountWithProviders from "../../../cypress/support/component-helper/cypress-mount";
@@ -77,4 +79,65 @@ context("Test for Button-Bar component", () => {
       cy.checkAccessibility();
     });
   });
+
+  describe("check ButtonBar can be navigated using a keyboard", () => {
+    it("should verify ButtonBar with wrapped components can be navigated using keyboard", () => {
+      CypressMountWithProviders(<ButtonBarCustom />);
+
+      buttonDataComponent().eq(0).focus();
+      buttonDataComponent().eq(0).tab();
+      buttonDataComponent().eq(1).should("be.focused");
+      buttonDataComponent().eq(0).should("not.be.focused");
+      buttonDataComponent().eq(1).tab();
+      buttonDataComponent().eq(2).should("be.focused");
+      buttonDataComponent().eq(1).should("not.be.focused");
+    });
+  });
+
+  describe("when custom Button wrapper components are used as children in ButtonBar", () => {
+    it("Button size is small when the size prop is set to small and passed to ButtonBar", () => {
+      CypressMountWithProviders(<ButtonBarWithWrapper size="small" />);
+
+      buttonDataComponent().then(($el) => {
+        useJQueryCssValueAndAssert($el, "width", 81);
+      });
+    });
+
+    it("Button is fullWidth when the fullWidth prop is passed to ButtonBar", () => {
+      CypressMountWithProviders(<ButtonBarWithWrapper fullWidth />);
+
+      buttonDataComponent().then(($el) => {
+        useJQueryCssValueAndAssert($el, "width", 339);
+      });
+    });
+
+    it.each([
+      ["after", "left"],
+      ["before", "right"],
+    ])(
+      "Button Icon position is %s text when the iconPosition is set and passed to ButtonBar",
+      (iconPosition, margin) => {
+        CypressMountWithProviders(
+          <ButtonBarWithWrapper iconPosition={iconPosition} />
+        );
+
+        icon().should("have.css", `margin-${margin}`, "8px");
+      }
+    );
+
+    it("should verify ButtonBar with wrapped components can be navigated using keyboard", () => {
+      CypressMountWithProviders(<ButtonBarWithWrapper />);
+
+      buttonDataComponent().eq(0).focus();
+      buttonDataComponent().eq(0).tab();
+      buttonDataComponent().eq(1).should("be.focused");
+      buttonDataComponent().eq(0).should("not.be.focused");
+      buttonDataComponent().eq(1).tab();
+      buttonDataComponent().eq(2).should("be.focused");
+      buttonDataComponent().eq(1).should("not.be.focused");
+      buttonDataComponent().eq(2).tab();
+      icon().eq(3).parent().should("be.focused");
+      buttonDataComponent().eq(2).should("not.be.focused");
+    });
+  });
 });