diff --git a/packages/scratch-gui/src/components/context-menu/menu-path-context.jsx b/packages/scratch-gui/src/components/context-menu/menu-path-context.jsx new file mode 100644 index 0000000000..7c562428c7 --- /dev/null +++ b/packages/scratch-gui/src/components/context-menu/menu-path-context.jsx @@ -0,0 +1,88 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import bindAll from 'lodash.bindall'; + +export const MenuRefContext = React.createContext(null); + +export class MenuRefProvider extends React.Component { + constructor (props) { + super(props); + + this.state = { + refStack: [] + }; + + bindAll(this, [ + 'push', + 'pop', + 'cut', + 'clear', + 'isTopMenu', + 'isOpenMenu' + ]); + } + + push (ref, depth) { + if (depth <= this.state.refStack.length) { + this.cut(this.state.refStack[depth - 1]); + } + + this.setState(prev => ({ + refStack: [...prev.refStack, ref] + })); + } + + pop () { + this.setState(prev => ({ + stack: prev.refStack.slice(0, prev.refStack.length - 1) + })); + } + + cut (ref) { + this.setState(prev => { + const refs = prev.refStack; + const index = refs.indexOf(ref); + + if (index === -1) return {refStack: refs}; + + return { + refStack: refs.slice(0, index) + }; + }); + } + + clear () { + this.setState({refStack: []}); + } + + isTopMenu (ref) { + const {refStack} = this.state; + return refStack.length > 0 && refStack[refStack.length - 1] === ref; + } + + isOpenMenu (ref) { + return this.state.refStack.includes(ref); + } + + render () { + const value = { + refStack: this.state.refStack, + push: this.push, + pop: this.pop, + cut: this.cut, + clear: this.clear, + isTopMenu: this.isTopMenu, + isOpenMenu: this.isOpenMenu + }; + + return ( + + {this.props.children} + + ); + } +} + +MenuRefProvider.propTypes = { + children: PropTypes.node +}; diff --git a/packages/scratch-gui/src/components/gui/gui.jsx b/packages/scratch-gui/src/components/gui/gui.jsx index 1fa06119e2..2f81df9eed 100644 --- a/packages/scratch-gui/src/components/gui/gui.jsx +++ b/packages/scratch-gui/src/components/gui/gui.jsx @@ -44,6 +44,7 @@ import soundsIcon from './icon--sounds.svg'; import DebugModal from '../debug-modal/debug-modal.jsx'; import {setPlatform} from '../../reducers/platform.js'; import {PLATFORM} from '../../lib/platform.js'; +import {MenuRefProvider} from '../context-menu/menu-path-context.jsx'; // Cache this value to only retrieve it once the first time. // Assume that it doesn't change for a session. @@ -252,42 +253,46 @@ const GUIComponent = props => { onRequestClose={onRequestCloseBackdropLibrary} /> ) : null} - {!menuBarHidden && } + {!menuBarHidden && + + + + } diff --git a/packages/scratch-gui/src/components/menu-bar/base-menu.jsx b/packages/scratch-gui/src/components/menu-bar/base-menu.jsx new file mode 100644 index 0000000000..d28458dc81 --- /dev/null +++ b/packages/scratch-gui/src/components/menu-bar/base-menu.jsx @@ -0,0 +1,121 @@ +import {MenuRefContext} from '../context-menu/menu-path-context'; +import React from 'react'; +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; + +/* Subclasses must implement (some optionally): +_______________________________________________ +render +onSelectItem +define this.itemRefs +add onKeyDown={this.handleKeyPress} +and onParentKeyPress={this.handleKeyPress} for MenuItem elements + +They should also receive: +______________________ +onOpen, +onClose, +focusedRef, +depth +*/ +export class BaseMenu extends React.PureComponent { + constructor (props) { + super(props); + bindAll(this, [ + 'onSelectItem', + 'handleKeyPress', + 'handleKeyPressOpenMenu', + 'handleMove', + 'handleOnOpen', + 'handleOnClose', + 'setFocusedRef' + ]); + + this.state = {focusedIndex: -1, depth: -1}; + this.focusedRef = props.focusedRef || React.createRef(); + } + + static contextType = MenuRefContext; + + setFocusedRef (ref) { + this.focusedRef = ref; + if (ref && ref.current) ref.current.focus(); + } + + handleKeyPress (e) { + if (this.props.depth === 1) { + if (e.key === 'Tab') { + this.handleOnClose(); + } + } + + if (this.context.isTopMenu(this.props.focusedRef)) { + this.handleKeyPressOpenMenu(e); + } else if (!this.context.isOpenMenu(this.props.focusedRef) && (e.key === ' ' || e.key === 'ArrowRight')) { + e.preventDefault(); + this.handleOnOpen(); + } + } + + handleKeyPressOpenMenu (e) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + this.handleMove(1); + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + this.handleMove(-1); + } + if (e.key === 'Enter') { + e.preventDefault(); + this.onSelectItem(); + } + if (e.key === 'ArrowLeft' || e.key === 'Escape') { + e.preventDefault(); + this.handleOnClose(); + } + } + + handleOnOpen () { + if (this.context.isOpenMenu(this.props.focusedRef)) return; + + this.props.onOpen(); + this.setState({focusedIndex: 0}, () => { + if (this.itemRefs[0] && this.itemRefs[0].current) this.itemRefs[0].current.focus(); + }); + + this.context.push(this.props.focusedRef, this.props.depth); + } + + handleMove (direction) { + const newIndex = (this.state.focusedIndex + direction + this.itemRefs.length) % this.itemRefs.length; + this.setState({focusedIndex: newIndex}, () => { + this.setFocusedRef(this.itemRefs[newIndex]); + }); + } + + onSelectItem () { + // do nothing by default, change for items that don't expand + } + + handleOnClose () { + this.context.cut(this.props.focusedRef); + this.setState({focusedIndex: -1}, () => { + this.setFocusedRef(this.props.focusedRef); + }); + + this.props.onClose(); + } + +} + +BaseMenu.propTypes = { + focusedRef: PropTypes.shape({current: PropTypes.instanceOf(Element)}), + depth: PropTypes.number, + onOpen: PropTypes.func, + onClose: PropTypes.func +}; + +BaseMenu.defaultProps = { + onClose: () => {} +}; diff --git a/packages/scratch-gui/src/components/menu-bar/edit-menu.jsx b/packages/scratch-gui/src/components/menu-bar/edit-menu.jsx new file mode 100644 index 0000000000..de47471b83 --- /dev/null +++ b/packages/scratch-gui/src/components/menu-bar/edit-menu.jsx @@ -0,0 +1,104 @@ +import React from 'react'; +import styles from './menu-bar.css'; +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import bindAll from 'lodash.bindall'; + +import editIcon from './icon--edit.svg'; +import {FormattedMessage} from 'react-intl'; +import MenuBarMenu from './menu-bar-menu.jsx'; +import {MenuItem, MenuSection} from '../menu/menu.jsx'; +import {BaseMenu} from './base-menu'; +import dropdownCaret from './dropdown-caret.svg'; +import DeletionRestorer from '../../containers/deletion-restorer.jsx'; +import TurboMode from '../../containers/turbo-mode.jsx'; + +export class EditMenu extends BaseMenu { + constructor (props) { + super(props); + + bindAll(this, ['handleOnClose', 'handleKeyPress', 'handleOnOpen']); + + this.state = {focusedIndex: -1}; + + this.restoreRef = React.createRef(); + this.turboRef = React.createRef(); + + this.itemRefs = [ + this.restoreRef, + this.turboRef + ]; + } + + render () { + return ( +
+ + + + + + + {(handleRestore, {restorable, deletedItem}) => ( + + {this.props.restoreOptionMessage(deletedItem)} + + )} + + {(toggleTurboMode, {turboMode}) => ( + + {turboMode ? ( + + ) : ( + + )} + + )} + + +
+ ); + } +} + +EditMenu.propTypes = { + focusedRef: PropTypes.shape({current: PropTypes.instanceOf(Element)}), + isRtl: PropTypes.bool, + restoreOptionMessage: PropTypes.func, + onRestoreOption: PropTypes.func +}; diff --git a/packages/scratch-gui/src/components/menu-bar/file-menu.jsx b/packages/scratch-gui/src/components/menu-bar/file-menu.jsx new file mode 100644 index 0000000000..195c56d845 --- /dev/null +++ b/packages/scratch-gui/src/components/menu-bar/file-menu.jsx @@ -0,0 +1,181 @@ +import React from 'react'; +import styles from './menu-bar.css'; +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import bindAll from 'lodash.bindall'; + +import fileIcon from './icon--file.svg'; +import {FormattedMessage} from 'react-intl'; +import MenuBarMenu from './menu-bar-menu.jsx'; +import {MenuItem, MenuSection} from '../menu/menu.jsx'; +import {BaseMenu} from './base-menu'; +import SB3Downloader from '../../containers/sb3-downloader.jsx'; +import dropdownCaret from './dropdown-caret.svg'; + +import sharedMessages from '../../lib/shared-messages'; +import intlShape from '../../lib/intlShape.js'; + +export class FileMenu extends BaseMenu { + constructor (props) { + super(props); + + bindAll(this, ['handleOnClose', 'handleKeyPress', 'handleOnOpen']); + + this.state = {focusedIndex: -1}; + + this.newProjectRef = React.createRef(); + this.saveRef = React.createRef(); + this.createRef = React.createRef(); + this.remixRef = React.createRef(); + this.loadFromComputerRef = React.createRef(); + this.saveToComputerRef = React.createRef(); + + this.itemRefs = [ + this.newProjectRef, + ...(this.props.canSave ? [this.saveRef] : []), + ...(this.props.canCreateCopy ? [this.createRef] : []), + ...(this.props.canRemix ? [this.remixRef] : []), + this.loadFromComputerRef, + this.saveToComputerRef + ]; + } + + render () { + const saveNowMessage = ( + + ); + const createCopyMessage = ( + + ); + const remixMessage = ( + + ); + const newProjectMessage = ( + + ); + return ( +
+ + + + + + + + + {newProjectMessage} + + + {(this.props.canSave || this.props.canCreateCopy || this.props.canRemix) && ( + + {this.props.canSave && ( + + {saveNowMessage} + + )} + {this.props.canCreateCopy && ( + + {createCopyMessage} + + )} + {this.props.canRemix && ( + + {remixMessage} + + )} + + )} + + + {this.props.intl.formatMessage(sharedMessages.loadFromComputerTitle)} + + {(className, downloadProjectCallback) => ( + + + + )} + + +
+ ); + } +} + +FileMenu.propTypes = { + focusedRef: PropTypes.shape({current: PropTypes.instanceOf(Element)}), + intl: intlShape, + isRtl: PropTypes.bool, + canSave: PropTypes.bool, + canCreateCopy: PropTypes.bool, + canRemix: PropTypes.bool, + onStartSelectingFileUpload: PropTypes.func, + onClickSave: PropTypes.func, + onClickSaveAsCopy: PropTypes.func, + onClickRemix: PropTypes.func, + onClickNew: PropTypes.func +}; diff --git a/packages/scratch-gui/src/components/menu-bar/language-menu.jsx b/packages/scratch-gui/src/components/menu-bar/language-menu.jsx index 81402c317a..927fec2ea7 100644 --- a/packages/scratch-gui/src/components/menu-bar/language-menu.jsx +++ b/packages/scratch-gui/src/components/menu-bar/language-menu.jsx @@ -9,20 +9,25 @@ import locales from 'scratch-l10n'; import check from './check.svg'; import {MenuItem, Submenu} from '../menu/menu.jsx'; import languageIcon from '../language-selector/language-icon.svg'; -import {languageMenuOpen, openLanguageMenu} from '../../reducers/menus.js'; +import {closeLanguageMenu, languageMenuOpen, openLanguageMenu} from '../../reducers/menus.js'; import {selectLocale} from '../../reducers/locales.js'; +import {MenuRefContext} from '../context-menu/menu-path-context.jsx'; import styles from './settings-menu.css'; import dropdownCaret from './dropdown-caret.svg'; +import {BaseMenu} from './base-menu'; -class LanguageMenu extends React.PureComponent { +class LanguageMenu extends BaseMenu { constructor (props) { super(props); bindAll(this, [ + 'onSelectItem', 'setRef', 'handleMouseOver' ]); + + this.itemRefs = Object.keys(locales).map(() => React.createRef()); } componentDidUpdate (prevProps) { @@ -32,26 +37,44 @@ class LanguageMenu extends React.PureComponent { } } + static contextType = MenuRefContext; + setRef (component) { this.selectedRef = component; } + onSelectItem () { + this.props.onChangeLanguage(Object.keys(locales)[this.state.focusedIndex]); + this.context.clear(); + } + handleMouseOver () { // If we are using hover rather than clicks for submenus, scroll the selected option into view if (!this.props.menuOpen && this.selectedRef) { this.selectedRef.scrollIntoView({block: 'center'}); + this.setFocusedRef(this.selectedRef); } } render () { + const { + currentLocale, + focusedRef, + isRtl, + onChangeLanguage + } = this.props; + return ( - +
{ Object.keys(locales) - .map(locale => ( - { + const isSelected = currentLocale === locale; + + return ( this.props.onChangeLanguage(locale)} + onClick={() => onChangeLanguage(locale)} + focusedRef={this.itemRefs[index]} + onParentKeyPress={this.handleKeyPress} > {locales[locale].name} - - )) + ); + }) } @@ -101,12 +128,12 @@ class LanguageMenu extends React.PureComponent { LanguageMenu.propTypes = { currentLocale: PropTypes.string, + focusedRef: PropTypes.shape({current: PropTypes.instanceOf(Element)}), isRtl: PropTypes.bool, - label: PropTypes.string, menuOpen: PropTypes.bool, onChangeLanguage: PropTypes.func, - onRequestCloseSettings: PropTypes.func, - onRequestOpen: PropTypes.func + onOpen: PropTypes.func, + onClose: PropTypes.func }; const mapStateToProps = state => ({ @@ -116,12 +143,12 @@ const mapStateToProps = state => ({ messagesByLocale: state.locales.messagesByLocale }); -const mapDispatchToProps = (dispatch, ownProps) => ({ +const mapDispatchToProps = dispatch => ({ onChangeLanguage: locale => { dispatch(selectLocale(locale)); - ownProps.onRequestCloseSettings(); }, - onRequestOpen: () => dispatch(openLanguageMenu()) + onOpen: () => dispatch(openLanguageMenu()), + onClose: () => dispatch(closeLanguageMenu()) }); export default connect( diff --git a/packages/scratch-gui/src/components/menu-bar/menu-bar.jsx b/packages/scratch-gui/src/components/menu-bar/menu-bar.jsx index 58f955a1bb..5c95483f5d 100644 --- a/packages/scratch-gui/src/components/menu-bar/menu-bar.jsx +++ b/packages/scratch-gui/src/components/menu-bar/menu-bar.jsx @@ -24,11 +24,10 @@ import ProjectTitleInput from './project-title-input.jsx'; import AuthorInfo from './author-info.jsx'; import AccountNav from '../../components/menu-bar/account-nav.jsx'; import LoginDropdown from './login-dropdown.jsx'; -import SB3Downloader from '../../containers/sb3-downloader.jsx'; -import DeletionRestorer from '../../containers/deletion-restorer.jsx'; -import TurboMode from '../../containers/turbo-mode.jsx'; import MenuBarHOC from '../../containers/menu-bar-hoc.jsx'; import SettingsMenu from './settings-menu.jsx'; +import {FileMenu} from './file-menu.jsx'; +import {EditMenu} from './edit-menu.jsx'; import {openTipsLibrary, openDebugModal} from '../../reducers/modals'; import {setPlayer} from '../../reducers/mode'; @@ -84,8 +83,6 @@ import profileIcon from './icon--profile.png'; import remixIcon from './icon--remix.svg'; import dropdownCaret from './dropdown-caret.svg'; import aboutIcon from './icon--about.svg'; -import fileIcon from './icon--file.svg'; -import editIcon from './icon--edit.svg'; import debugIcon from '../debug-modal/icons/icon--debug.svg'; import scratchLogo from './scratch-logo.svg'; @@ -197,6 +194,10 @@ class MenuBar extends React.Component { 'getSaveToComputerHandler', 'restoreOptionMessage' ]); + + this.settingsRef = React.createRef(); + this.fileRef = React.createRef(); + this.editRef = React.createRef(); } componentDidMount () { document.addEventListener('keydown', this.handleKeyPress); @@ -289,6 +290,11 @@ class MenuBar extends React.Component { }; } handleKeyPress (event) { + if (event.key === 'Enter') { + event.preventDefault(); + event.target.click(); + } + const modifier = bowser.mac ? event.metaKey : event.ctrlKey; if (modifier && event.key === 's') { this.props.onClickSave(); @@ -385,20 +391,6 @@ class MenuBar extends React.Component { }; } render () { - const saveNowMessage = ( - - ); - const createCopyMessage = ( - - ); const remixMessage = ( ); - const newProjectMessage = ( - - ); const remixButton = (