4

I have an intersectionObserver that watches some sections and highlights the corresponding navigation item. But I've only managed to get the "main sections Microsoft, Amazon working, but not the subsections Define, Branding, Design, Deduction. As seen in the gif below:

The reason why I want it structured this way is so that I can highlight the "main" sections if the subsections are in view.

Semi working demo: https://codesandbox.io/s/intersection-with-hooks-fri5jun1344-fe03x

Current

It might seems that I might be able to copy and paste the same functionality with the subsections as well. But I'm having a hard time wrapping my head around how to deal with nested data + useRef + reducer. I was wondering if someone could give me a pointer in the right direction.

Here is an gif of the desired effect. Notice the main title (Loupe, Canon) are still highlighted if one of the subsections are in view:

It all starts with an data array

const data = [
  {
    title: "Microsoft",
    id: "microsoft",
    color: "#fcf6f5",
    year: "2020",
    sections: ["define", "branding", "design", "deduction"]
  },
  {
    title: "Amazon",
    id: "amazon",
    color: "#FFE2DD",
    year: "2018",
    sections: ["define", "design", "develop", "deduction"]
  },
  {
    title: "Apple",
    id: "apple",
    color: "#000",
    year: "2020",
    sections: ["about", "process", "deduction"]
  }
];

App.js padding data object into reduce to create Refs

  const refs = data.reduce((refsObj, Case) => {
    refsObj[Case.id] = React.createRef();
    return refsObj;
  }, {});

My components passing in the props

          <Navigation
            data={data}
            handleClick={handleClick}
            activeCase={activeCase}
          />
          {data.map(item => (
            <Case
              key={item.id}
              activeCase={activeCase}
              setActiveCase={setActiveCase}
              refs={refs}
              data={item}
            />
          ))}

Case.js

export function Case({ data, refs, activeCase, setActiveCase }) {
  const components = {
    amazon: Amazon,
    apple: Apple,
    microsoft: Microsoft
  };

  class DefaultError extends Component {
    render() {
      return <div>Error, no page found</div>;
    }
  }
  const Tag = components[data.id] || DefaultError;

  useEffect(() => {
    const observerConfig = {
      rootMargin: "-50% 0px -50% 0px",
      threshold: 0
    };
    const observer = new IntersectionObserver(entries => {
      entries.forEach(entry => {
        if (entry.target.id !== activeCase && entry.isIntersecting) {
          setActiveCase(entry.target.id);
        }
      });
    }, observerConfig);

    observer.observe(refs[data.id].current);
    return () => observer.disconnect(); // Clenaup the observer if unmount
  }, [activeCase, setActiveCase, refs, data]);

  return (
    <React.Fragment>
      <section
        ref={refs[data.id]}
        id={data.id}
        className="section"
        style={{ marginBottom: 400 }}
      >
        <Tag data={data} />
      </section>
    </React.Fragment>
  );
}

I've tried mapping the subsections like this but I get stuck at this part:

   const subRefs = data.map((refsObj, Case) => {
     refsObj[Case] = React.createRef();
     return refsObj;
   }, {});
umbriel
  • 723
  • 1
  • 7
  • 22
  • out of curiosity what did you use to make the animated gifs? did you use a browser extension for capture and then gifmaker with editing, or do you have a tool that makes it simpler? – user120242 Jun 07 '20 at 20:10
  • 1
    I use Mac’s cmd+shift+5 screen recorder -> Gifski export – umbriel Jun 07 '20 at 20:14

2 Answers2

1

Working Example

I've found a solution while trying to keep most of your logic intact. Firstly what you need to do is to store the subrefs (the sections ref) in the same object as your Case ref. So you will need an extra reduce function to create those inside the refs object:

App.js

const refs = data.reduce((refsObj, Case) => { // Put this outside the render
    const subRefs = Case.sections.reduce((subrefsObj, Section) => {
      subrefsObj[Section] = React.createRef();
      return subrefsObj;
    }, {});

    refsObj[Case.id] = {
      self: React.createRef(), // self is the Case ref, like Apple, Microsoft...
      subRefs // This is going to be the subrefs
    };
    return refsObj;
  }, {});

Then you add an extra state to handle which sub section is active, like const [activeSection, setActiveSection] = React.useState(); And you put it anywhere you also use the activeCase. You need that because you said that the Case and Sections need to work independently. (Both active at the same time).

Case.js

You will need to pass along the subrefs to the child components, so you do:

    <Tag data={data} subRefs={refs[data.id].subRefs} />

And you will also need the intersection observer for each of the subrefs. So your useEffect will look like:

 useEffect(() => {
    const observerConfig = {
      rootMargin: "-50% 0px -50% 0px",
      threshold: 0
    };

    const observerCallback = (entries, isCase) => {
      const activeEntry = entries.find(entry => entry.isIntersecting);

      if (activeEntry) {
        if (isCase) setActiveCase(activeEntry.target.id);
        else setActiveSection(activeEntry.target.id);
      } else if (isCase) {
        setActiveCase(null);
        setActiveSection(null);
      }
    };

    const caseObserver = new IntersectionObserver(
      entries => observerCallback(entries, true),
      observerConfig
    );
    caseObserver.observe(refs[data.id].self.current);

    const sectionObserver = new IntersectionObserver(
      entries => observerCallback(entries, false),
      observerConfig
    );

    Object.values(refs[data.id].subRefs).forEach(subRef => {
      sectionObserver.observe(subRef.current);
    });

    return () => {
      caseObserver.disconnect();
      sectionObserver.disconnect();
    }; // Clenaup the observer if unmount
  }, [refs, data]);

Then in your amazon/index.js ,microsoft/index.js and apple/index.js files. You pass along the ref again:

<Template
        data={this.props.data}
        caseSections={caseSections}
        subRefs={this.props.subRefs}
      />

Finally, in your template.js file you will have the following so you can assign the right ref:

const Template = props => {
  return (
    <React.Fragment>
      <div
        sx={{
          background: "#eee",
          transition: "background ease 0.5s"
        }}
      >
        {props.data.sections &&
          props.data.sections.map(subItem => (
            <Container
              ref={props.subRefs && props.subRefs[subItem]}
              id={`${props.data.id}--${subItem}`}
              key={subItem}
              className="article"
            >
              <Section sectionId={subItem} caseSections={props.caseSections} />
            </Container>
          ))}
      </div>
    </React.Fragment>
  );
};

I believe most of it is covered in the post. You can check your forked working repo here

perellorodrigo
  • 536
  • 5
  • 11
  • 1
    Hey perellorodrigo. Thanks so much for taking the time to help me out. It all works as expected when I apply it to my own code base. One question I have is if its possible to unobserve the sections if its not in view. Notice how the very first activeCase (Microsoft) is unhighlighted on pageload. But if you scroll down its gets highlighted and if you scroll up to the top outside the IO rootMargin rules, its still highlighted. Thanks! – umbriel Jun 08 '20 at 09:47
  • Hey @SebastianGraz, I've just edited the answer to support that. Basically you check if the entry is not intersecting. If is not intersecting and the entry is the same as the selected case. You set the `selectedCase` and `selectedSection` to null. – perellorodrigo Jun 08 '20 at 10:36
  • Amazing. Dont want to push my luck here, but is there any reason why both setActiveCase and setActiveSection are fired twice within the observerCallback on `console.log(entry.target.id);`? It sometimes leads to infinite loops if it gets stuck between two sections. And leads to weird bugs like this shown in the gif: https://gfycat.com/bluefancyanhinga – umbriel Jun 08 '20 at 12:33
  • The reason I ask is because I'm using the section ids to change background color and as I scroll by I get a spasm of color on the screen from the multiple callbacks, maybe timeout can be a solution? – umbriel Jun 08 '20 at 12:35
  • I just extended the same logic you were using, so I'm not exactly sure. I will have a look though. – perellorodrigo Jun 08 '20 at 12:36
  • @SebastianGraz The issue was with the `forEach` loop for entries. When more than one intersection was in view, it would cause both to be overriding each other. I edited the answer now with the fixed code. So it gets the first entry only. – perellorodrigo Jun 08 '20 at 13:01
  • 1
    Thats amazing! Unfortunately it looks like it broke the subsection being in view. At least looking at (https://bvtdt.csb.app/) Its not being highlighted like in the previous verions. React is being really annoying with this one ... – umbriel Jun 08 '20 at 15:08
  • Look at it now. The issue was that the refs was set in the render method in App.js, you need to move outside of it, so it wont recreate them when the component rerenders. I've also change a bit the observerCallback. Please accept the answer if it works correctly. thanks! – perellorodrigo Jun 08 '20 at 23:39
  • 1
    Hey thanks again perellorodrigo, you've helped more than enough. I really appreciate the time you spent on this. Marked as the accepted answer + rep. Sometimes on scroll the main title doesn't get highlighted but I think I can debug from here :) – umbriel Jun 09 '20 at 08:51
  • 1
    Glad to help! maybe you can improve it by only using the subrefs and find a way to get the parent and highlight when the child is on view. – perellorodrigo Jun 10 '20 at 01:57
0

You can simplify your code. You don't really need refs or intersectionObservers for your use case. You can simply scrollIntoView using document.getElementById (you already have ids to your navs.

You can do setActiveCase very well in handleClick.

Working demo

Modify handleClick like this


const handleClick = (subTabId, mainTabName) => {
    //console.log("subTabName, mainTabName", subTabId, mainTabName);
    setActiveCase({ mainTab: mainTabName, subTab: subTabId.split("--")[1] }); //use this for active tab styling etc
    document.getElementById(subTabId).scrollIntoView({
      behavior: "smooth",
      block: "start"
    });
  };

Navigation.js Call handleClick like this.

{item.sections &&
            item.sections.map(subItem => (
              <div
                className={`${styles.anchor}`}
                key={`#${item.title}--${subItem}`}
                sx={{ marginRight: 3, fontSize: 0, color: "text" }}
                href={`#${item.title}--${subItem}`}
                onClick={e => {
                  handleClick(`${item.id}--${subItem}`, item.id);
                  e.stopPropagation();
                }}
              >
                {toTitleCase(subItem)}
              </div>
            ))}
gdh
  • 13,114
  • 2
  • 16
  • 28