3

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>
    );
  }
}
user992731
  • 3,400
  • 9
  • 53
  • 83

2 Answers2

2

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]
      });
    }
  }
omikes
  • 8,064
  • 8
  • 37
  • 50
  • 1
    @oMiKeY- Absoulte legend mate. What is "~~" by the way? – user992731 Jan 31 '19 at 02:50
  • 1
    its a little shortcut i like to do that is just more fun to write than parseInt(). it turns a string number into an integer. – omikes Jan 31 '19 at 02:53
  • 1
    One other thing. What I am missing to get it to tab using the arrow-key without clicking on a button? It works perfectly however, it just doesn't fire until a button is clicked first – user992731 Jan 31 '19 at 02:53
  • try putting `window.focus();` at the bottom of index.js – omikes Jan 31 '19 at 03:18
  • @user992731 I redid it a bit cleaner in case you planned on changing the labels. Now they don't have to have a number in them or anything. – omikes Jan 31 '19 at 05:27
1

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);
}
Matt Carlotta
  • 18,972
  • 4
  • 39
  • 51