Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Getting duplicates when Incrementing & Decrementing using the Arrow keys with React JS

I came back to this problem of trying to increment/decrement through a Menu and about to drive my head though wall figuring this out. I feel I'm almost there, but missing something. I am running into not being able to arrow right until I click on a button as well the button I clicked doesn't remove the class. Any help would much appreciated.

Button js:

...

class Button extends Component {
  onClick() {
    const { label, onClick } = this.props;
    onClick(label);
  }

  render() {
    const {
      onClick,
      props: { activeTab, label, tab, className }
    } = this;
    let ariaSelected = "";
    if (activeTab === label || className === "active") {
      ariaSelected += "true";
    }
    return (
      <li role="presentation">
        <a
          className={className}
          aria-selected={ariaSelected ? "true" : undefined} 
          onClick={e => this.onClick(e)}
          role="tab"
          id={"tab" + tab}
          //tabIndex="-1"
        >
          {label}
        </a>
      </li>
    );
  }
}

..

Menu Js:

class Menu extends Component {
  constructor(props) {
    super(props);
    this.state = {
      activeTab: this.props.children[0].props.label,
      cursor: 0
    };
    this.handleKeyDown = this.handleKeyDown.bind(this);
  }

  componentDidMount() {
    document.addEventListener("keydown", this.handleKeyDown, false);
  }

  componentWillUnmount() {
    document.removeEventListener("keydown", this.handleKeyDown, false);
  }

  handleKeyDown(e) {
    const { cursor } = this.state;
    const cnt = React.Children.count(this.props.children);
    if (e.keyCode === 37 && cursor > 0) {
      this.setState(prevState => ({
        cursor: prevState.cursor - 1
      }));
      console.log(cursor);
    } else if (e.keyCode === 39 && cursor < cnt - 1) {
      this.setState(prevState => ({
        cursor: prevState.cursor + 1
      }));
      console.log(cursor);
    }
  }

  onClickTabItem = tab => {
    this.setState({
      activeTab: tab
    });
  };

  render() {
    const {
      onClickTabItem,
      props: { children },
      state: { activeTab, cursor, className }
    } = this;

    return (
      <div className="tabbed">
        <ul role="tablist">
          {children.map((child, i) => {
            const { label, className } = child.props;
            return (
              <Tab
                activeTab={activeTab}
                key={label}
                label={label}
                onClick={onClickTabItem}
                tab={i}
                className={ cursor === i || activeTab === label ? "active" : null}
              />
            );
          })}
        </ul>
        <div className="tab-content">
          {children.map(child => {
            //if tab has label or active set, otherwise do nohthing
            if (child.props.label !== activeTab) return undefined;
            return child.props.children;
          })}
        </div>
      </div>
    );
  }
}
like image 300
user992731 Avatar asked Feb 28 '26 00:02

user992731


2 Answers

You could grab the label number from activeTab instead of having a cursor variable for when arrow keys are pressed:

  handleKeyDown(e) {
    const cnt = React.Children.count(this.props.children);
    const pos = ~~this.state.activeTab[8]  // get current position
    if (e.keyCode === 37 && pos > 1) {
      this.setState({
        activeTab: "Section " + (pos - 1)
      });
    } else if (e.keyCode === 39 && pos < cnt) {
      this.setState({
        activeTab: "Section " + (pos + 1)
      });
    }
  }

and then where className is set change cursor === i || activeTab === label ? "active" : null to just activeTab === label ? "active" : null

edit: This way is a little cleaner, in case you plan on changing section titles, which I imagine you might.

  handleKeyDown(e) {
    const labels = this.props.children.map((child) => {
      return child.props.label;
    });
    const cnt = labels.length;
    const pos = labels.indexOf(this.state.activeTab); // get current position
    if (e.keyCode === 37 && pos > 0) {
      this.setState({
        activeTab: labels[pos - 1]
      });
    } else if (e.keyCode === 39 && pos < cnt - 1) {
      this.setState({
        activeTab: labels[pos + 1]
      });
    }
  }
like image 185
omikes Avatar answered Mar 01 '26 12:03

omikes


The above has quite a bit of repetitive and unnecessary code. I've spent some time refactoring and making it a bit more modular/easier to use.

Working example: https://codesandbox.io/s/rlwq35oz4o

Changes:

  • Tabs acts like a container for multiple Tab components
  • Tab is a simple reuseable component that requires a title and children
  • All tabs are now managed by activeTab state and compared against a mapped key (this can be easily changed/implemented for a unique id)
  • Utilizing prevProps.children.length to determine tab length in the setState callback
  • Changed TabPane's a (link element) to b (an unformatted text element) because it caused a styling issue with links embedded into the TabBody utilizing the same role="tablist"
  • Added word-wrap: break-word; to a elements within the TabBody to prevent them from breaking the section for small screens.

index.js

import React from "react";
import { render } from "react-dom";
import Tabs, { Tab } from "./components/Tabs";
import "./styles.css";

const App = () => (
  <Tabs>
    <Tab title="Section 1">
      Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam euismod,
      tortor nec pharetra ultricies, ante erat imperdiet velit, nec laoreet enim
      lacus a velit.<a href="#">Nam luctus</a>, enim in interdum condimentum,
      nisl diam iaculis lorem, vel volutpat mi leo sit amet lectus. Praesent non
      odio bibendum magna bibendum accumsan.
    </Tab>
    <Tab title="Section 2">
      Nullam at diam nec arcu suscipit auctor non a erat. Sed et magna semper,
      eleifend magna non, facilisis nisl. Proin et est et lorem dictum finibus
      ut nec turpis. Aenean nisi tortor, euismod a mauris a, mattis scelerisque
      tortor. Sed dolor risus, varius a nibh id, condimentum lacinia est. In
      lacinia cursus odio a aliquam. Curabitur tortor magna, laoreet ut rhoncus
      at, sodales consequat
    </Tab>
    <Tab title="Section 3">
      Phasellus ac tristique orci. Nulla maximus
      <a href="">justo nec dignissim consequat</a>. Sed vehicula diam sit amet
      mi efficitur vehicula in in nisl. Aliquam erat volutpat. Suspendisse lorem
      turpis, accumsan consequat consectetur gravida,
      <a href="#">pellentesque ac ante</a>. Aliquam in commodo ligula, sit amet
      mollis neque. Vestibulum at facilisis massa.
    </Tab>
    <Tab title="Section 4">
      Nam luctus, enim in interdum condimentum, nisl diam iaculis lorem, vel
      volutpat mi leo sit amet lectus. Praesent non odio bibendum magna bibendum
      accumsan. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam
      euismod, tortor nec pharetra ultricies, ante erat imperdiet velit, nec
      laoreet enim lacus a velit.
    </Tab>
  </Tabs>
);

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

components/Tabs.js

import React, { Component } from "react";
import PropTypes from "prop-types";
import TabPane from "./TabPane";
import TabBody from "./TabBody";

export const Tab = ({ title, children }) => <div tab={title}>{children}</div>;

Tab.propTypes = {
  children: PropTypes.node.isRequired,
  title: PropTypes.string.isRequired
};

class Tabs extends Component {
  state = { activeTab: 0 };

  componentDidMount = () => {
    document.addEventListener("keydown", this.handleKeyPress, false);
    window.focus();
  }

  componentWillUnmount = () =>
    document.removeEventListener("keydown", this.handleKeyPress, false);

  handleClickTabItem = ({ target: { id } }) =>
    this.setState({ activeTab: ~~id });

  handleKeyPress = ({ keyCode }) => {
    if (keyCode === 37 || keyCode === 39) {
      this.setState((prevState, prevProps) => {
        const nextTab = keyCode === 37 
          ? prevState.activeTab - 1 
          : prevState.activeTab + 1;

        return nextTab >= 0 && nextTab < prevProps.children.length
          ? { activeTab: nextTab }
          : null;
      });
    }
  };

  render = () => {
    const { activeTab } = this.state;
    const { children } = this.props;
    return (
      <div className="tabbed">
        <ul role="tablist">
          {children.map(({ props }, key) => (
            <TabPane
              key={key}
              activeTab={activeTab}
              onClickTabItem={this.handleClickTabItem}
              id={key}
              {...props}
            />
          ))}
          <div className="tab-content">
            {children.map(({ props }, key) =>
              key === activeTab ? (
                <TabBody key={key} id={key} {...props} />
              ) : null
            )}
          </div>
        </ul>
      </div>
    );
  };
}

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

export default Tabs;

components/TabPane.js

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

const TabPane = ({ activeTab, id, onClickTabItem, title, ...rest }) => (
  <li role="presentation">
    <b
      id={id}
      aria-selected={activeTab === id ? "true" : null}
      onClick={onClickTabItem}
      role="tab"
      {...rest}
    >
      {title}
    </b>
  </li>
);

TabPane.propTypes = {
  activeTab: PropTypes.number.isRequired,
  id: PropTypes.number.isRequired,
  onClickTabItem: PropTypes.func.isRequired,
  title: PropTypes.string.isRequired
};

export default TabPane;

components/TabBody.js

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

const TabBody = ({ title, id, children }) => (
  <section id={id} role="tabpanel" tabIndex="-1" aria-labelledby={id}>
    <h2>{title}</h2>
    <div>{children}</div>
  </section>
);

TabBody.propTypes = {
  children: PropTypes.node.isRequired,
  id: PropTypes.number.isRequired,
  title: PropTypes.string.isRequired
};

export default TabBody;

styles.css

body {
  max-width: 40rem;
  padding: 0 1rem;
  font-size: 125%;
  line-height: 1.5;
  margin: 1.5rem auto;
  font-family: "Lato", Arial, sans-serif;
  font-size: 16px;
}

* {
  color: inherit;
  margin: 0;
}

[role="tablist"] {
  padding: 0;
}

[role="tablist"] li,
[role="tablist"] b {
  display: inline-block;
}

[role="tablist"] b {
  text-decoration: none;
  padding: 0.5rem 1em;
  cursor: pointer;
}

[role="tablist"] a {
  text-decoration: none;
  padding-left: 0.2rem;
  word-wrap: break-word;
}

[role="tablist"] [aria-selected] {
  border: 2px solid;
  background: #fff;
  border-bottom: 0;
  position: relative;
  top: 2px;
}

[role="tabpanel"] {
  border: 2px solid;
  padding: 1.5rem;
}

[role="tabpanel"] * + * {
  margin-top: 0.75rem;
}

*:focus {
  outline: none;
  box-shadow: inset 0 0 0 4px lightBlue;
}

@media (max-width: 550px) {
  [role="tablist"] li,
  [role="tablist"] b {
    display: block;
    position: static;
  }

  [role="tablist"] b {
    border: 2px solid #222 !important;
  }

  [role="tablist"] li + li b {
    border-top: 0 !important;
  }

  [role="tablist"] [aria-selected] {
    position: static;
  }

  [role="tablist"] [aria-selected]::after {
    content: "\0020⬅";
  }

  [role="tabpanel"] {
    border-top: 0;
  }
}

section a {
  color: rgb(66, 133, 244);
}
like image 31
Matt Carlotta Avatar answered Mar 01 '26 13:03

Matt Carlotta



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!