UNPKG

thinkful-ui

Version:

Shared UI resources for Thinkful.

355 lines (305 loc) 13.5 kB
"use strict"; function _typeof(obj) { "@babel/helpers - typeof"; if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); if (superClass) _setPrototypeOf(subClass, superClass); } function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); } function _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; } function _possibleConstructorReturn(self, call) { if (call && (_typeof(call) === "object" || typeof call === "function")) { return call; } return _assertThisInitialized(self); } function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; } function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === "function") return true; try { Date.prototype.toString.call(Reflect.construct(Date, [], function () {})); return true; } catch (e) { return false; } } function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); } var cx = require('classnames'); var escapeStringRegexp = require('escape-string-regexp'); var marked = require('marked'); var PropTypes = require('prop-types'); var React = require('react'); var Icon = require('../Icon'); var Tag = require('../Tag'); var DOWN_ARROW_KEY_CODE = 40; var UP_ARROW_KEY_CODE = 38; var COMMA_KEY_CODE = 188; var RETURN_KEY_CODE = 13; var DEFAULT_MIN_TOPIC_LENGTH = 1; var mapLower = function mapLower(v) { return v.toLowerCase(); }; /* * TopicPicker component * * Interface: * use the `getTopics` function on the component to get the internal * list of picked topics * * @property {Array} availableTopics the Array of topic to suggest * @property {Array} activeTopics the Array of topics to prefill * @property {Boolean} addMatchEmphasis Add `em` tags around tag matches * @property {String} className a className to add to component * @property {Func} handleUpdateTopics callback func on topic update * @property {Number} maxSuggestions The max number of suggestions to show * @property {Number} minTopicLength The min length a topic string must be */ var TopicPicker = /*#__PURE__*/function (_React$Component) { _inherits(TopicPicker, _React$Component); var _super = _createSuper(TopicPicker); function TopicPicker(props) { var _this; _classCallCheck(this, TopicPicker); _this = _super.call(this, props); // Private methods _this._filterTopicList = _this._filterTopicList.bind(_assertThisInitialized(_this)); _this._handleKeyDown = _this._handleKeyDown.bind(_assertThisInitialized(_this)); _this._handleMoveSelected = _this._handleMoveSelected.bind(_assertThisInitialized(_this)); _this._handlePatternChange = _this._handlePatternChange.bind(_assertThisInitialized(_this)); _this._handleAddTopic = _this._handleAddTopic.bind(_assertThisInitialized(_this)); _this._handleRemoveTopic = _this._handleRemoveTopic.bind(_assertThisInitialized(_this)); _this._handleTopicSubmit = _this._handleTopicSubmit.bind(_assertThisInitialized(_this)); _this._toggleFocus = _this._toggleFocus.bind(_assertThisInitialized(_this)); // Public methods _this.getTopics = _this.getTopics.bind(_assertThisInitialized(_this)); _this.state = { pattern: '', topics: props.activeTopics, selectedSuggestionIndex: -1 }; return _this; } _createClass(TopicPicker, [{ key: "componentWillReceiveProps", value: function componentWillReceiveProps(newProps) { this.setState({ topics: newProps.activeTopics }); } /* * Return the `availableTopics`, filtered for a matching pattern * * This makes the topics markdown to add emphasis if the property * `addMatchEmphasis` is truthy. */ }, { key: "_filterTopicList", value: function _filterTopicList(additionalTopics) { var _this$props = this.props, addMatchEmphasis = _this$props.addMatchEmphasis, availableTopics = _this$props.availableTopics, maxSuggestions = _this$props.maxSuggestions; var _this$state = this.state, pattern = _this$state.pattern, topics = _this$state.topics; // in case topic is something like `C++` var normalizedPattern = escapeStringRegexp(this.state.pattern.toLowerCase()); // find and mark the pattern matches in a case-insensitive way return availableTopics // filter for matching available topics .filter(function (topic) { return topic.toLowerCase().match(normalizedPattern) && topics.indexOf(topic) < 0; }) // limit the number of results .slice(0, maxSuggestions) // add the asterisks for emphasis around matching area .map(function (topic) { if (addMatchEmphasis) { var firstIndex = topic.toLowerCase().indexOf(topic.toLowerCase().match(normalizedPattern)[0]); var lastIndex = firstIndex + pattern.length + 1; var topicArray = topic.split(''); topicArray.splice(firstIndex, 0, '*'); topicArray.splice(lastIndex, 0, '*'); return topicArray.join(''); } return topic; }); } }, { key: "_handleKeyDown", value: function _handleKeyDown(event) { var selectedSuggestionIndex = this.state.selectedSuggestionIndex; var suggestions = this._filterTopicList(); if (event.which === DOWN_ARROW_KEY_CODE) { this.setState({ selectedSuggestionIndex: Math.min(selectedSuggestionIndex + 1, suggestions.length - 1) }); } else if (event.which === UP_ARROW_KEY_CODE) { this.setState({ selectedSuggestionIndex: Math.max(selectedSuggestionIndex - 1, 0) }); } else if (event.which == COMMA_KEY_CODE || event.which == RETURN_KEY_CODE) { this._handleTopicSubmit(event); } } /* * Move the selected suggestion by `numMoves` * * Usable in keydown handler for moving selected topic in suggestion list */ }, { key: "_handleMoveSelected", value: function _handleMoveSelected(numMoves) { var availableTopics = this.props.availableTopics; var selectedSuggestionIndex = this.state.selectedSuggestionIndex; var newSelectedIndex = selectedSuggestionIndex + numMoves; if (newSelectedIndex >= -1 && newSelectedIndex < (availableTopics || []).length) { this.setState({ selectedSuggestionIndex: newSelectedIndex }); } } /* * Handler for a form change */ }, { key: "_handlePatternChange", value: function _handlePatternChange(event) { this.setState({ pattern: event.target.value }); } /* * Add a topic to the internal list of topics */ }, { key: "_handleAddTopic", value: function _handleAddTopic(topic) { var topics = this.state.topics; var handleUpdateTopics = this.props.handleUpdateTopics; if (topics.map(mapLower).indexOf(topic.toLowerCase()) < 0) { // We trim the whitespace off the tag before adding it var newTopics = topics.concat(topic.trim()); this.setState({ topics: newTopics }); handleUpdateTopics(newTopics); } this.setState({ pattern: '', selectedSuggestionIndex: -1 }); } /* * Remove a topic from the internal list of topics, if it's present * * Matching is case-sensitive */ }, { key: "_handleRemoveTopic", value: function _handleRemoveTopic(topic) { var topics = this.state.topics; var handleUpdateTopics = this.props.handleUpdateTopics; var newTopics = topics.filter(function (t) { return t !== topic; }); this.setState({ topics: newTopics }); handleUpdateTopics(newTopics); } /* * Handle submission of a new topic */ }, { key: "_handleTopicSubmit", value: function _handleTopicSubmit(event) { if (event && event.preventDefault) { event.preventDefault(); } var selectedSuggestionIndex = this.state.selectedSuggestionIndex; var topic; if (selectedSuggestionIndex > -1) { topic = this._filterTopicList()[selectedSuggestionIndex]; } else { topic = this.state.pattern; } if (topic.trim().length >= this.props.minTopicLength) { this._handleAddTopic(topic.trim()); } } }, { key: "_toggleFocus", value: function _toggleFocus() { this.setState({ isFocused: !this.state.isFocused }); } /* * Getter for topics */ }, { key: "getTopics", value: function getTopics() { return this.state.topics; } }, { key: "render", value: function render() { var _this2 = this; var _this$props2 = this.props, className = _this$props2.className, placeholderText = _this$props2.placeholderText; var _this$state2 = this.state, isFocused = _this$state2.isFocused, pattern = _this$state2.pattern, selectedSuggestionIndex = _this$state2.selectedSuggestionIndex, topics = _this$state2.topics; return /*#__PURE__*/React.createElement("div", { className: cx('topic-picker', className, isFocused && 'topic-picker-focus') }, topics.map(function (topic, index) { return /*#__PURE__*/React.createElement(Tag, { key: index, className: "topic", displayName: topic, forceEnabled: true }, /*#__PURE__*/React.createElement("div", { className: "topic-delete-button", onClick: function onClick(event) { return _this2._handleRemoveTopic(topic); } }, /*#__PURE__*/React.createElement(Icon, { name: "close" }))); }), /*#__PURE__*/React.createElement("div", { className: "topic-form", onKeyDown: this._handleKeyDown, onSubmit: this._handleTopicSubmit }, /*#__PURE__*/React.createElement("input", { onFocus: this._toggleFocus, onBlur: this._toggleFocus, className: "topic-form-input", type: "text", value: pattern, placeholder: placeholderText, onChange: this._handlePatternChange }), pattern && /*#__PURE__*/React.createElement("ul", { className: "topic-suggestion-list" }, this._filterTopicList().map(function (topic, index) { return /*#__PURE__*/React.createElement("li", { key: index, className: cx('topic-list-item', { selected: index === selectedSuggestionIndex }), onClick: function onClick(event) { return _this2._handleAddTopic(topic.replace(/\*/g, '')); }, dangerouslySetInnerHTML: { __html: marked(topic) } }); })))); } }]); return TopicPicker; }(React.Component); TopicPicker.propTypes = { activeTopics: PropTypes.array, availableTopics: PropTypes.array, addMatchEmphasis: PropTypes.bool, className: PropTypes.string, handleUpdateTopics: PropTypes.func, maxSuggestions: PropTypes.number, minTopicLength: PropTypes.number }; TopicPicker.defaultProps = { availableTopics: [], activeTopics: [], maxSuggestions: 10, minTopicLength: DEFAULT_MIN_TOPIC_LENGTH, placeholderText: "Add a tag (hit 'return' after each one)", // if parent doesn't pass in callback, to avoid conditionals inline handleUpdateTopics: function handleUpdateTopics() { return null; } }; module.exports = TopicPicker;