Home Reference Source

src/components/TabBar.js

/* eslint-disable no-plusplus, react/jsx-one-expression-per-line */
/**
 * 
 *  ██▓ ███▄    █  ██ ▄█▀     ██████  ██▓███   ██▓    ▄▄▄     ▄▄▄█████▓
 *  ▓██▒ ██ ▀█   █  ██▄█▒    ▒██    ▒ ▓██░  ██▒▓██▒   ▒████▄   ▓  ██▒ ▓▒
 *  ▒██▒▓██  ▀█ ██▒▓███▄░    ░ ▓██▄   ▓██░ ██▓▒▒██░   ▒██  ▀█▄ ▒ ▓██░ ▒░
 *  ░██░▓██▒  ▐▌██▒▓██ █▄      ▒   ██▒▒██▄█▓▒ ▒▒██░   ░██▄▄▄▄██░ ▓██▓ ░
 *  ░██░▒██░   ▓██░▒██▒ █▄   ▒██████▒▒▒██▒ ░  ░░██████▒▓█   ▓██▒ ▒██▒ ░
 *  ░▓  ░ ▒░   ▒ ▒ ▒ ▒▒ ▓▒   ▒ ▒▓▒ ▒ ░▒▓▒░ ░  ░░ ▒░▓  ░▒▒   ▓▒█░ ▒ ░░
 *  ▒ ░░ ░░   ░ ▒░░ ░▒ ▒░   ░ ░▒  ░ ░░▒ ░     ░ ░ ▒  ░ ▒   ▒▒ ░   ░
 *  ▒ ░   ░   ░ ░ ░ ░░ ░    ░  ░  ░  ░░         ░ ░    ░   ▒    ░
 *  ░           ░ ░  ░            ░               ░  ░     ░  ░
 * 
 * LICENSE: MIT
 */
/**
 * A substantial portion of this code came from https://github.com/jdeniau/ink-tab
 * My additions are concerned with making it look like a TabBar but not behaving like one
 * if it isn't in focus.
 */

import React from 'react';
import readline from 'readline';
// import PropTypes from 'prop-types';
import { Box, Color, StdinContext, Text } from 'ink';
import Control from './Control';
import { isClassOrComponentFactory } from '../utils/isClassOrComponent';

export class Tab extends Control {
    /**
     * Returns a boolean which indicates if the provided `anyComponentOrClass` parameter
     * is an instance of `Tab` (if passed an object) or a class derived from
     * `Tab` (if passed a function) or a component derived from `Tab`.
     * 
     * @param {*} anyComponentOrClass Any valid React component or React component class or instance
     * or derived component or class or instance.
     * @return `true` if `anyComponentOrClass` validates, `false` otherwise.
     */
    static isTab(anyComponentOrClass) {
      return isClassOrComponentFactory(Tab)(anyComponentOrClass)
    }
  
    render() {
      const { props } = this;
      return props.children;
    }
}

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

class TabsWithStdin extends Control {
  constructor(props) {
    super(props);
    this.isColumn = this.isColumn.bind(this);
    this.handleTabChange = this.handleTabChange.bind(this);
    this.handleKeyPress = this.handleKeyPress.bind(this);
    this.moveToNextTab = this.moveToNextTab.bind(this);
    this.moveToPreviousTab = this.moveToPreviousTab.bind(this);
    this.state = {
      ...this.state,
    };
  }

  componentDidMount() {
    const { activeTab, stdin, setRawMode } = this.props;
    // use ink / node `setRawMode` to read key-by-key
    setRawMode(true);
    // and listen to keypress events
    readline.emitKeypressEvents(stdin);
    stdin.on('keypress', this.handleKeyPress);
    // select the first tab on component mount (if not active tab is selected)
    if (!activeTab)
      this.handleTabChange(0);
  }

  componentWillUnmount() {
    const { stdin } = this.props;
    stdin.removeListener('keypress', this.handleKeyPress);
  }

  handleTabChange(tabId) {
    const { onChange, onTabIdChange } = this.props;
    const tab = this.props.children[tabId];
    if (!tab) {
      return;
    }
    if (onChange)
        onChange(tab.props.name, tab);
    if (onTabIdChange)
        onTabIdChange(tabId);
  }

  handleKeyPress(ch, key) {
    if (!key) {
      return;
    }
    switch (key.name) {
      case 'left': {
        if (this.isColumn()) {
          return;
        }
        this.moveToPreviousTab();
        break;
      }
      case 'up':
        if (!this.isColumn()) {
          return;
        }
        this.moveToPreviousTab();
        break;
      case 'right': {
        if (this.isColumn()) {
          return;
        }
        this.moveToNextTab();
        break;
      }
      case 'down':
        if (!this.isColumn()) {
          return;
        }
        this.moveToNextTab();
        break;
      // case 'tab':
      //   if (true === key.shift) {
      //     this.moveToPreviousTab();
      //   } else {
      //     this.moveToNextTab();
      //   }
      //   break;
      // }
      case 'tab':
      case 'return': {
        this.blur();
        break;
      }
      case '0':
      case '1':
      case '2':
      case '3':
      case '4':
      case '5':
      case '6':
      case '7':
      case '8':
      case '9':
        if (key.meta === true) {
          const tabId = key.name === '0' ? 9 : parseInt(key.name, 10) - 1;
          this.handleTabChange(tabId);
        }
        break;
      default:        
    }
  }

  isColumn() {
    const { flexDirection } = this.props;

    return flexDirection === 'column' || flexDirection === 'column-reverse';
  }

  moveToNextTab() {
    const { activeTab, children } = this.props;
    let nextTabId = activeTab + 1;
    if (nextTabId >= children.length) {
      nextTabId = 0;
    }

    this.handleTabChange(nextTabId);
  }

  moveToPreviousTab() {
    const { activeTab, children } = this.props;
    let nextTabId = activeTab - 1;
    if (nextTabId < 0) {
      nextTabId = children.length - 1;
    }

    this.handleTabChange(nextTabId);
  }

  render() {
    const { activeTab, children, onChange, flexDirection, ...rest } = this.props;

    const separator = this.isColumn() ? '──────' : ' | ';

    return (
      <Box flexDirection={flexDirection} {...rest}>
        {children.map((child, key) => {
          const { name } = child.props;

          return (
            <Box key={name} flexDirection={flexDirection}>
              {key !== 0 && <Color dim>{separator}</Color>}
              <Box>
                <Color keyword="grey">
                  <Text>{key + 1}. </Text>
                </Color>
                <Color
                  bgGreen={activeTab === key}
                  black={activeTab === key}
                >
                  {child}
                </Color>
              </Box>
            </Box>
          );
        })}
      </Box>
    );
  }
}

class TabsWithoutStdin extends Control {
    constructor(props) {
      super(props);
  
      this.isColumn = this.isColumn.bind(this);
    }
      
    isColumn() {
      const { flexDirection } = this.props;
  
      return flexDirection === 'column' || flexDirection === 'column-reverse';
    }
  
    render() {
      const { activeTab, children, onChange, flexDirection, ...rest } = this.props;
  
      const separator = this.isColumn() ? '──────' : ' | ';
  
      return (
        <Box flexDirection={flexDirection} {...rest}>
          {children.map((child, key) => {
            const { name } = child.props;
  
            return (
              <Box key={name} flexDirection={flexDirection}>
                {key !== 0 && <Color dim>{separator}</Color>}
                <Box>
                  <Color keyword="grey">{key + 1}. </Color>
                  <Color
                    bgGreen={activeTab === key}
                    black={activeTab === key}
                  >
                    {child}
                  </Color>
                </Box>
              </Box>
            );
          })}
        </Box>
      );
    }
}

class Tabs extends Control {
  render() {
    return (
      <StdinContext.Consumer>
        {({ stdin, setRawMode }) => (
          <TabsWithStdin stdin={stdin} setRawMode={setRawMode} {...this.props} />
        )}
      </StdinContext.Consumer>
    );
  }
}

class ShadedTabs extends Control {
  render() {
    return (
      <TabsWithoutStdin {...this.props} />
    );
  }
} 

// Tabs.propTypes = {
//   onChange: PropTypes.func.isRequired,
//   children: PropTypes.node.isRequired,
// };

export class TabBar extends Control {
    /**
     * Returns a boolean which indicates if the provided `anyComponentOrClass` parameter
     * is an instance of `TabBar` (if passed an object) or a class derived from
     * `TabBar` (if passed a function) or a component derived from `TabBar`.
     * 
     * @param {*} anyComponentOrClass Any valid React component or React component class or instance
     * or derived component or class or instance.
     * @return `true` if `anyComponentOrClass` validates, `false` otherwise.
     */
    static isTabBar(anyComponentOrClass) {
      return isClassOrComponentFactory(TabBar)(anyComponentOrClass)
    }

    // static ERROR_NO_TABS_PROVIDED = 'TabBar must contain at least one Tab control.';
    
    /**
     * This error will be thrown if you pass any children other than Tab components.
     */
    static ERROR_ONLY_TABS_ALLOWED = 'TabBar children must contain only Tab controls.';
    
    /**
     * This might not ever be thrown.
     */
    static ERROR_UNKNOWN_ERROR = 'An unknown error has occurred while validating TabBar child controls.';

    /**
     * Validates a `children` array to make sure it is valid.
     * 
     * @param {*} children An array of Tab components or null or undefined.
     * @param {*} throwOnError If true, throws an error if invalid.
     * @return `true` if valid, `false` otherwise when `throwOnError` is `false`
     * @throws An error when `throwOnError` is `true` if children contains anything
     * other than a Tab control or if it is an empty array `[]` or `null` or `undefined`.
     */
    static validateChildren(children, throwOnError) {
      // condition 1 - when children is an array
      if (Array.isArray(children)) {
        // omitted condition 2 - children array cannot be empty
        // if (children.length === 0) {
        //   if (throwOnError)
        //     throw TabBar.ERROR_NO_TABS_PROVIDED;
        //   return false;
        // }
        for (let c = 0; c < children.length; c++) {
          const child = children[c];
          // condition 3 - ensure each child is a Tab
          if (!Tab.isTab(child)) {
            if (throwOnError)
              throw TabBar.ERROR_ONLY_TABS_ALLOWED;
            return false;
          }
        }
        return true;
      }
      if (children === undefined || children === null) {
        // omitted condition 4 - children must be provided
        // if (throwOnError)
        //   throw TabBar.ERROR_NO_TABS_PROVIDED;
        // return false;
        // allow it:
        return true;
      }
      if (throwOnError) {
        throw TabBar.ERROR_UNKNOWN_ERROR;
      }
      return false;
    }
    
    constructor(props) {
        super(props);
        // TODO: hoist state upward to outer container
        this.state = {
            ...this.state,
            activeTab: 0
        };
        this.handleTabIdChange = this.handleTabIdChange.bind(this);
        if (this.props.children.length > 0 && this.props.onChange) {
          const tab = this.props.children[0];
          this.props.onChange(tab.props.name, tab);
        }
    }
    
    handleTabIdChange(activeTab) {
        this.setState({activeTab});
    }

    render() {
        return (
            this.isFocused()
            ?
            (
              <Tabs
                {...this.props}
                activeTab={this.state.activeTab}
                onTabIdChange={this.handleTabIdChange}
              />
            )
            :
            (
              <ShadedTabs
                {...this.props}
                activeTab={this.state.activeTab}
              />
            )
        );
    }
}