4

I have a few accordion components created using the compound component methodology (there is a great talk by Ryan Florence describing compound components here).

One of the ESLint rules I have setup is import/no-cycle to prevent dependency cycles. As I'm using the compound component methodology as well as the advised way to refer to other components in Styled Components by keeping all styles relevant to a particular component in that file itself, I am triggering the import/no-cycle warning.

Here is my Accordion.js file.

import React, { useState } from "react";

import AccordionTrigger from "./accordionTrigger";
import AccordionContent from "./accordionContent";

const Accordion = ({ children, show }) => {
  const [isActive, setIsActive] = useState(show);

  const handleTriggerClick = () => {
    setIsActive(!isActive);
  };

  const compoundChildren = React.Children.map(children, child => {
    switch (child.type) {
      case AccordionTrigger:
        return React.cloneElement(child, {
          onClick: handleTriggerClick,
          active: isActive ? 1 : 0,
        });

      case AccordionContent:
        return React.cloneElement(child, {
          show: isActive,
        });

      default:
        return child;
    }
  });

  return <div show={show ? 1 : 0}>{compoundChildren}</div>;
};

export default Accordion;

And my AccordionTrigger.js file.

import React from "react";
import styled from "styled-components";

import FauxButton from "../buttons/fauxButton";

import Accordion from "./accordion";
import TopLevelTrigger from "./topLevelTrigger";
import SecondaryLevelTrigger from "./secondaryLevelTrigger";

const Root = styled(FauxButton)`
  ${Accordion} & {
    width: 100%;
    border-bottom: 1px solid ${p => p.theme.greyLight};
  }
`;

const AccordionTrigger = ({ active, children, ...rest }) => {
  const clonedChildren = React.Children.map(children, child => {
    switch (child.type) {
      case TopLevelTrigger:
      case SecondaryLevelTrigger:
        return React.cloneElement(child, {
          active,
        });

      default:
        return child;
    }
  });
  return <Root {...rest}>{clonedChildren}</Root>;
};

export default AccordionTrigger;

One thing I have tried is defining the AccordionTrigger styles in Accordion.js like so...

const Root = styled.div`
  ${AccordionTrigger} & {
    width: 100%;
    border-bottom: 1px solid ${p => p.theme.greyLight};
  }
`;

const Accordion = ({ children, show }) => {
  ...same logic as before here

  return <Root show={show ? 1 : 0}>{compoundChildren}</Root>;
};

...but this doesn't work, the styles simply don't get added to the AccordionTrigger component. I know I can simply add my own class via the className prop when cloning inside of the Accordion component and then refer to it that way but I was wondering if there was a way to prevent this?

GuerillaRadio
  • 1,267
  • 5
  • 29
  • 59

1 Answers1

3

In short, styled-components generates a className that must be applied to an HTML/JSX Element in order to see styles. In addition, the composed component must be an instance of a styled component. My example here explains a basic approach to styling a custom component.

Since, I don't have your full code, I recreated the example from the video you linked.

Working example (in this case, I'm styling TabContent within Tab and altering its svg element):

Edit cool-sinoussi-spdpq

In addition, I structured my files like so, which avoids the import recursion issues:

├── src
|   ├── components
|   |   ├── Tab
|   |   |   ├── Tab.js
|   |   |   └── index.js
|   |   |
|   |   ├── TabContent
|   |   |   ├── TabContent.js
|   |   |   └── index.js
|   |   | 
|   |   ├── TabList
|   |   |   └── index.js
|   |   | 
|   |   ├── TabPanel
|   |   |   └── index.js
|   |   | 
|   |   ├── TabPanels
|   |   |   ├── TabPanels.js
|   |   |   └── index.js
|   |   | 
|   |   └── Tabs
|   |       └── index.js
|   └── index.js
|
├── index.js
└── tabs.js

src/components/Tab/Tab.js

import React from "react";
import PropTypes from "prop-types";

const Tab = ({ children, className, disabled, onSelectTab }) => (
  <div className={className} onClick={disabled ? null : onSelectTab}>
    {children}
  </div>
);

Tab.propTypes = {
  className: PropTypes.string.isRequired,
  children: PropTypes.node.isRequired,
  disabled: PropTypes.bool,
  onSelectTab: PropTypes.func.isRequired
};

export default Tab;

src/components/Tab/index.js

import styled from "styled-components";
import Tab from "./Tab";
import TabContent from "../TabContent";

export default styled(Tab)`
  display: inline-block;
  padding: 10px;
  margin: 10px;
  border-bottom: 2px solid;
  border-color: rgba(0, 0, 0, 0.65);
  color: rgba(0, 0, 0, 0.65);
  cursor: pointer;
  -webkit-transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
  transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
  ${({ disabled, isActive }) => {
    if (disabled) return `opacity: 0.25; cursor: default;`;
    if (isActive) return `color: #1890ff; border-bottom-color: #1890ff;`;
  }}

  &:hover {
    color: #40a9ff;
    border-bottom-color: #40a9ff;
    ${({ disabled }) =>
      disabled &&
      `color: rgba(0, 0, 0, 0.65); border-bottom-color: rgba(0, 0, 0, 0.65);`};
  }

  ${TabContent} {
    & svg {
      font-size: 13px;
    }
  }
`;

src/components/TabContent/TabContent.js

import React from "react";
import PropTypes from "prop-types";

const TabContent = ({ children, className }) => (
  <div className={className}>{children}</div>
);

TabContent.propTypes = {
  className: PropTypes.string.isRequired,
  children: PropTypes.node.isRequired
};

export default TabContent;

src/components/TabContent/index.js

import styled from "styled-components";
import TabContent from "./TabContent";

export default styled(TabContent)`
  font-size: 20px;
`;

src/components/TabList/index.js

import { Children, cloneElement, useCallback } from "react";
import PropTypes from "prop-types";

const TabList = ({ activeIndex, children, setActiveIndex }) => {
  const handleSelectedTab = useCallback(
    index => {
      setActiveIndex(index);
    },
    [setActiveIndex]
  );

  return Children.map(children, (child, index) =>
    cloneElement(child, {
      isActive: activeIndex === index,
      onSelectTab: () => handleSelectedTab(index)
    })
  );
};

TabList.propTypes = {
  activeIndex: PropTypes.number,
  children: PropTypes.node.isRequired,
  setActiveIndex: PropTypes.func
};

export default TabList;

src/components/TabPanel/index.js

import PropTypes from "prop-types";

const TabPanel = ({ children }) => children;

TabPanel.propTypes = {
  children: PropTypes.node.isRequired
};

export default TabPanel;

src/components/TabPanels/TabPanels.js

import React, { Children } from "react";
import PropTypes from "prop-types";

const TabPanels = ({ activeIndex, children, className }) => (
  <div className={className}>{Children.toArray(children)[activeIndex]}</div>
);

TabPanels.propTypes = {
  activeIndex: PropTypes.number,
  children: PropTypes.node.isRequired,
  setActiveIndex: PropTypes.func
};

export default TabPanels;

src/components/TabPanels/index.js

import styled from "styled-components";
import TabPanels from "./TabPanels";

export default styled(TabPanels)`
  padding: 10px;
`;

src/components/Tabs/Tabs.js

import { Children, cloneElement, useState } from "react";
import PropTypes from "prop-types";
import TabPanels from "../TabPanels";
import TabList from "../TabList";

const Tabs = ({ children }) => {
  const [activeIndex, setActiveIndex] = useState(0);

  return Children.map(children, child => {
    switch (child.type) {
      case TabPanels: {
        return cloneElement(child, { activeIndex });
      }
      case TabList: {
        return cloneElement(child, {
          activeIndex,
          setActiveIndex
        });
      }
      default: {
        return child;
      }
    }
  });
};

Tabs.propTypes = {
  children: PropTypes.node.isRequired
};

export default Tabs;

src/components/index.js

export { default as Tab } from "./Tab";
export { default as TabContent } from "./TabContent";
export { default as TabList } from "./TabList";
export { default as TabPanel } from "./TabPanel";
export { default as TabPanels } from "./TabPanels";
export { default as Tabs } from "./Tabs";

src/index.js

import React from "react";
import ReactDOM from "react-dom";
import {
  Tab,
  TabContent,
  Tabs,
  TabList,
  TabPanels,
  TabPanel
} from "./components";
import tabs from "./tabs";

const App = () => (
  <Tabs>
    <TabList>
      {tabs.map(({ icon, title, disabled }) => (
        <Tab key={title} disabled={disabled}>
          <TabContent>
            {icon} {title}
          </TabContent>
        </Tab>
      ))}
    </TabList>
    <TabPanels>
      {tabs.map(({ title, content }) => (
        <TabPanel key={title}>{content}</TabPanel>
      ))}
    </TabPanels>
  </Tabs>
);

ReactDOM.render(<App />, document.getElementById("root"));

src/tabs.js

import React from "react";
import { FaAppleAlt, FaCarrot, FaLemon } from "react-icons/fa";

export default [
  {
    title: "Apples",
    icon: <FaAppleAlt />,
    content: <p>Apples are delicious.</p>
  },
  {
    title: "Carrots",
    icon: <FaCarrot />,
    content: <p>Carrots are nutritious.</p>,
    disabled: true
  },
  {
    title: "Lemons",
    icon: <FaLemon />,
    content: <p>Lemons are ubiquitous.</p>
  }
];
Matt Carlotta
  • 18,972
  • 4
  • 39
  • 51
  • Thanks for this answer and the CodeSandbox Matt, it is extremely helpful. I have done as you have suggested and created a separate styled component in a separate file called `AccordionRoot.js` which is then imported in `Accordion.js` and rendered where I was previously returning `
    `. This same component is then referenced in Root styled component in `AccordionTrigger.js` rather than referencing `Accordion`. I can confirm this approach works and solves the dependency cycle problem!
    – GuerillaRadio Sep 10 '19 at 15:06
  • Awesome! Glad you were able to figure it out! – Matt Carlotta Sep 10 '19 at 16:25