From 1d41b849cb7840a1611d34f20bfe636fc8b903dc Mon Sep 17 00:00:00 2001 From: vantezzen Date: Fri, 20 Sep 2019 12:34:56 +0200 Subject: Implement Quick Switch feature --- src/features/quickSwitch/Component.js | 291 ++++++++++++++++++++++++++++++++++ src/features/quickSwitch/index.js | 24 +++ 2 files changed, 315 insertions(+) create mode 100644 src/features/quickSwitch/Component.js create mode 100644 src/features/quickSwitch/index.js (limited to 'src/features/quickSwitch') diff --git a/src/features/quickSwitch/Component.js b/src/features/quickSwitch/Component.js new file mode 100644 index 000000000..3217a3d93 --- /dev/null +++ b/src/features/quickSwitch/Component.js @@ -0,0 +1,291 @@ +import React, { Component, createRef } from 'react'; +import PropTypes from 'prop-types'; +import { observer, inject } from 'mobx-react'; +import { reaction } from 'mobx'; +import injectSheet from 'react-jss'; +import { defineMessages, intlShape } from 'react-intl'; +import { Input } from '@meetfranz/forms'; + +import Modal from '../../components/ui/Modal'; +import { state as ModalState } from '.'; +import ServicesStore from '../../stores/ServicesStore'; + +const messages = defineMessages({ + search: { + id: 'feature.quickSwitch.search', + defaultMessage: '!!!Search...', + }, + info: { + id: 'feature.quickSwitch.info', + defaultMessage: '!!!Select a service with TAB, ↑ and ↓. Open a service with ENTER.', + }, +}); + +const styles = theme => ({ + modal: { + width: '80%', + maxWidth: 600, + background: theme.styleTypes.primary.contrast, + color: theme.styleTypes.primary.accent, + paddingTop: 30, + }, + services: { + width: '100%', + marginTop: 30, + }, + service: { + background: theme.styleTypes.primary.contrast, + color: theme.colorText, + borderColor: theme.styleTypes.primary.accent, + borderStyle: 'solid', + borderWidth: 1, + borderRadius: 6, + padding: '3px 25px', + marginBottom: 10, + display: 'flex', + alignItems: 'center', + '&:hover': { + background: theme.styleTypes.primary.accent, + color: theme.styleTypes.primary.contrast, + cursor: 'pointer', + }, + }, + activeService: { + background: theme.styleTypes.primary.accent, + color: theme.styleTypes.primary.contrast, + cursor: 'pointer', + }, + serviceIcon: { + width: 50, + height: 50, + paddingRight: 20, + objectFit: 'contain', + }, +}); + +export default @injectSheet(styles) @inject('stores', 'actions') @observer class QuickSwitchModal extends Component { + static propTypes = { + classes: PropTypes.object.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + state = { + selected: 0, + search: '', + wasPrevVisible: false, + } + + ARROW_DOWN = 40; + + ARROW_UP = 38; + + ENTER = 13; + + TAB = 9; + + inputRef = createRef(); + + constructor(props) { + super(props); + + this._handleKeyDown = this._handleKeyDown.bind(this); + this._handleSearchUpdate = this._handleSearchUpdate.bind(this); + this._handleVisibilityChange = this._handleVisibilityChange.bind(this); + this.openService = this.openService.bind(this); + + reaction( + () => ModalState.isModalVisible, + this._handleVisibilityChange, + ); + } + + // Add global keydown listener when component mounts + componentDidMount() { + document.addEventListener('keydown', this._handleKeyDown); + } + + // Remove global keydown listener when component unmounts + componentWillUnmount() { + document.removeEventListener('keydown', this._handleKeyDown); + } + + // Get currently shown services + services() { + let services = this.props.stores.services.allDisplayed; + if (this.state.search) { + // Apply simple search algorythm + services = services.filter(service => service.name.toLowerCase().includes(this.state.search.toLowerCase())); + } + + return services; + } + + openService(index) { + // Open service + const service = this.services()[index]; + this.props.actions.service.setActive({ serviceId: service.id }); + + // Reset and close modal + this.setState({ + search: '', + }); + this.close(); + } + + // Change the selected service + // factor should be -1 or 1 + changeSelected(factor) { + this.setState((state) => { + let newSelected = state.selected + factor; + const services = this.services().length; + + // Roll around when on edge of list + if (state.selected < 1 && factor === -1) { + newSelected = services - 1; + } else if ((state.selected >= (services - 1)) && factor === 1) { + newSelected = 0; + } + + return { + selected: newSelected, + }; + }); + } + + // Handle global key presses to change the selection + _handleKeyDown(event) { + if (ModalState.isModalVisible) { + switch (event.keyCode) { + case this.ARROW_DOWN: + this.changeSelected(1); + break; + case this.TAB: + this.changeSelected(1); + break; + case this.ARROW_UP: + this.changeSelected(-1); + break; + case this.ENTER: + this.openService(this.state.selected); + break; + default: + break; + } + } + } + + // Handle update of the search query + _handleSearchUpdate(evt) { + this.setState({ + search: evt.target.value, + }); + } + + _handleVisibilityChange() { + const { isModalVisible } = ModalState; + + if (isModalVisible && !this.state.wasPrevVisible) { + // Set focus back on current window if its in a service + // TODO: Find a way to gain back focus + + // The input "focus" attribute will only work on first modal open + // Manually add focus to the input element + // Wrapped inside timeout to let the modal render first + setTimeout(() => { + if (this.inputRef.current) { + this.inputRef.current.getElementsByTagName('input')[0].focus(); + } + }, 10); + + this.setState({ + wasPrevVisible: true, + }); + } else if (!isModalVisible && this.state.wasPrevVisible) { + // Manually blur focus from the input element to prevent + // search query change when modal not visible + setTimeout(() => { + if (this.inputRef.current) { + this.inputRef.current.getElementsByTagName('input')[0].blur(); + } + }, 100); + + this.setState({ + wasPrevVisible: false, + }); + } + } + + // Close this modal + close() { + ModalState.isModalVisible = false; + } + + render() { + const { isModalVisible } = ModalState; + + const { + openService, + } = this; + + const { + classes, + } = this.props; + + const services = this.services(); + + const { intl } = this.context; + + return ( + +
+ +
+ +
+ { services.map((service, index) => ( +
openService(index)} + key={service.id} + > + {service.recipe.name} +
+ { service.name } +
+
+ ))} +
+ +

{intl.formatMessage(messages.info)}

+
+ ); + } +} + +QuickSwitchModal.wrappedComponent.propTypes = { + stores: PropTypes.shape({ + services: PropTypes.instanceOf(ServicesStore).isRequired, + }).isRequired, + actions: PropTypes.shape({ + service: PropTypes.shape({ + setActive: PropTypes.func.isRequired, + }).isRequired, + }).isRequired, +}; diff --git a/src/features/quickSwitch/index.js b/src/features/quickSwitch/index.js new file mode 100644 index 000000000..c57fad366 --- /dev/null +++ b/src/features/quickSwitch/index.js @@ -0,0 +1,24 @@ +import { observable } from 'mobx'; + +export { default as Component } from './Component'; + +const debug = require('debug')('Ferdi:feature:quickSwitch'); + +const defaultState = { + isModalVisible: false, +}; + +export const state = observable(defaultState); + +export default function initialize() { + debug('Initialize quickSwitch feature'); + + function showModal() { + state.isModalVisible = true; + } + + window.ferdi.features.quickSwitch = { + state, + showModal, + }; +} -- cgit v1.2.3-70-g09d2