{ + const { + menuRef, + intl, + isRtl, + onRestoreOption, + restoreOptionMessage + } = props; + + const restoreRef = useRef(null); + const turboRef = useRef(null); + + const itemRefs = [restoreRef, turboRef]; + + const { + isExpanded, + handleKeyPress, + handleKeyPressOpenMenu, + handleOnOpen, + handleOnClose + } = useMenuNavigation({ + menuRef, + itemRefs, + depth: 1 + }); + + return ( +
+ + + + + + + {(handleRestore, {restorable, deletedItem}) => ( + + {restoreOptionMessage(deletedItem)} + + )} + + {(toggleTurboMode, {turboMode}) => ( + + {turboMode ? ( + + ) : ( + + )} + + )} + + +
+ ); +}; + +EditMenu.propTypes = { + intl: intlShape.isRequired, + menuRef: propTypes.ref.isRequired, + isRtl: PropTypes.bool, + restoreOptionMessage: PropTypes.func.isRequired, + onRestoreOption: PropTypes.func.isRequired +}; + +const mapStateToProps = state => ({ + isRtl: state.locales.isRtl +}); + +export default connect( + mapStateToProps +)(EditMenu); 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..a4581724c3 --- /dev/null +++ b/packages/scratch-gui/src/components/menu-bar/file-menu.jsx @@ -0,0 +1,224 @@ +import React, {useRef} from 'react'; +import styles from './menu-bar.css'; +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import {connect} from 'react-redux'; + +import fileIcon from './icon--file.svg'; +import {FormattedMessage, defineMessage} from 'react-intl'; +import MenuBarMenu from './menu-bar-menu.jsx'; +import {MenuItem, MenuSection} from '../menu/menu.jsx'; +import SB3Downloader from '../../containers/sb3-downloader.jsx'; +import dropdownCaret from './dropdown-caret.svg'; +import useMenuNavigation from '../../hooks/use-menu-navigation.jsx'; + +import sharedMessages from '../../lib/shared-messages'; +import intlShape from '../../lib/intlShape.js'; +import propTypes from '../../lib/prop-types.js'; + +import { + manualUpdateProject, + remixProject, + saveProjectAsCopy +} from '../../reducers/project-state'; + +const fileMenu = defineMessage({ + id: 'gui.aria.fileMenu', + defaultMessage: 'File menu', + description: 'ARIA label for file menu' +}); + +const FileMenu = props => { + const { + intl, + isRtl, + menuRef, + canSave, + canCreateCopy, + canRemix, + onClickNew, + onClickSave, + onClickSaveAsCopy, + onClickRemix, + onStartSelectingFileUpload, + getSaveToComputerHandler, + remixMessage + } = props; + + const newProjectRef = useRef(null); + const saveRef = useRef(null); + const createRef = useRef(null); + const remixRef = useRef(null); + const loadFromComputerRef = useRef(null); + const saveToComputerRef = useRef(null); + + const itemRefs = [ + newProjectRef, + ...(canSave ? [saveRef] : []), + ...(canCreateCopy ? [createRef] : []), + ...(canRemix ? [remixRef] : []), + loadFromComputerRef, + saveToComputerRef + ]; + + const { + isExpanded, + handleKeyPress, + handleKeyPressOpenMenu, + handleOnOpen, + handleOnClose + } = useMenuNavigation({ + menuRef, + itemRefs, + depth: 1 + }); + + const saveNowMessage = ( + + ); + const createCopyMessage = ( + + ); + const newProjectMessage = ( + + ); + + return ( +
+ + + + + + + + + {newProjectMessage} + + + {(canSave || canCreateCopy || canRemix) && ( + + {canSave && ( + + {saveNowMessage} + + )} + {canCreateCopy && ( + + {createCopyMessage} + + )} + {canRemix && ( + + {remixMessage} + + )} + + )} + + + {intl.formatMessage(sharedMessages.loadFromComputerTitle)} + + {(className, downloadProjectCallback) => ( + + + + )} + + +
+ ); +}; + +FileMenu.propTypes = { + menuRef: propTypes.ref.isRequired, + intl: intlShape.isRequired, + isRtl: PropTypes.bool, + canSave: PropTypes.bool.isRequired, + canCreateCopy: PropTypes.bool.isRequired, + canRemix: PropTypes.bool.isRequired, + onStartSelectingFileUpload: PropTypes.func.isRequired, + onClickSave: PropTypes.func, + onClickSaveAsCopy: PropTypes.func, + onClickRemix: PropTypes.func, + onClickNew: PropTypes.func.isRequired, + getSaveToComputerHandler: PropTypes.func.isRequired, + remixMessage: PropTypes.node.isRequired +}; + +const mapStateToProps = state => ({ + isRtl: state.locales.isRtl +}); + +const mapDispatchToProps = dispatch => ({ + onClickRemix: () => dispatch(remixProject()), + onClickSave: () => dispatch(manualUpdateProject()), + onClickSaveAsCopy: () => dispatch(saveProjectAsCopy()) +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(FileMenu); 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..9e615a3ce6 100644 --- a/packages/scratch-gui/src/components/menu-bar/language-menu.jsx +++ b/packages/scratch-gui/src/components/menu-bar/language-menu.jsx @@ -1,127 +1,154 @@ import classNames from 'classnames'; -import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; -import React from 'react'; -import {FormattedMessage} from 'react-intl'; +import React, {useCallback, useEffect, useRef} from 'react'; +import {FormattedMessage, defineMessage} from 'react-intl'; import {connect} from 'react-redux'; 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 {selectLocale} from '../../reducers/locales.js'; +import useMenuNavigation from '../../hooks/use-menu-navigation.jsx'; import styles from './settings-menu.css'; +import intlShape from '../../lib/intlShape.js'; import dropdownCaret from './dropdown-caret.svg'; +import propTypes from '../../lib/prop-types.js'; -class LanguageMenu extends React.PureComponent { - constructor (props) { - super(props); - bindAll(this, [ - 'setRef', - 'handleMouseOver' - ]); - } +const languageMenu = defineMessage({ + id: 'gui.aria.languageMenu', + defaultMessage: 'Language menu', + description: 'ARIA label for language menu' +}); + +const LanguageMenu = props => { + const { + intl, + currentLocale, + menuRef, + isRtl, + onChangeLanguage + } = props; + + const itemRefs = React.useMemo( + () => Object.keys(locales).map(() => React.createRef()), + [] + ); + let selectedRef = useRef(null); - componentDidUpdate (prevProps) { - // If the submenu has been toggled open, try scrolling the selected option into view. - if (!prevProps.menuOpen && this.props.menuOpen && this.selectedRef) { - this.selectedRef.scrollIntoView({block: 'center'}); + const { + isExpanded, + handleKeyPress, + handleKeyPressOpenMenu, + handleOnOpen + } = useMenuNavigation({ + menuRef, + itemRefs, + depth: 2, + defaultIndexOnOpen: (Object.keys(locales).indexOf(currentLocale)) + }); + + useEffect(() => { + const selectedIndex = Object.keys(locales).indexOf(currentLocale); + if (isExpanded() && selectedIndex >= 0 && itemRefs[selectedIndex]?.current) { + itemRefs[selectedIndex].current.scrollIntoView({block: 'center'}); } - } + }, [currentLocale, isExpanded, itemRefs]); - setRef (component) { - this.selectedRef = component; - } + const setRef = useCallback(component => { + selectedRef = component; + }, []); - handleMouseOver () { + const handleMouseOver = useCallback(() => { // 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'}); + if (isExpanded() && selectedRef) { + selectedRef.scrollIntoView({block: 'center'}); } - } + }, [isExpanded]); - render () { - return ( - +
-
- - - - - + + -
- - { - Object.keys(locales) - .map(locale => ( - this.props.onChangeLanguage(locale)} - > - - {locales[locale].name} - - )) - } - - - ); - } -} + + +
+ + { + Object.keys(locales) + .map((locale, index) => { + const isSelected = currentLocale === locale; + + return ( onChangeLanguage(locale)} + itemRef={itemRefs[index]} + onParentKeyPress={handleKeyPressOpenMenu} + isSelected={isSelected} + > + + {locales[locale].name} + ); + }) + } + +
+ ); +}; LanguageMenu.propTypes = { + menuRef: propTypes.ref.isRequired, + intl: intlShape.isRequired, currentLocale: PropTypes.string, isRtl: PropTypes.bool, - label: PropTypes.string, - menuOpen: PropTypes.bool, - onChangeLanguage: PropTypes.func, - onRequestCloseSettings: PropTypes.func, - onRequestOpen: PropTypes.func + onChangeLanguage: PropTypes.func }; const mapStateToProps = state => ({ currentLocale: state.locales.locale, isRtl: state.locales.isRtl, - menuOpen: languageMenuOpen(state), messagesByLocale: state.locales.messagesByLocale }); -const mapDispatchToProps = (dispatch, ownProps) => ({ +const mapDispatchToProps = dispatch => ({ onChangeLanguage: locale => { dispatch(selectLocale(locale)); - ownProps.onRequestCloseSettings(); - }, - onRequestOpen: () => dispatch(openLanguageMenu()) + } }); 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 5df827e5aa..a6a1cf2249 100644 --- a/packages/scratch-gui/src/components/menu-bar/menu-bar.jsx +++ b/packages/scratch-gui/src/components/menu-bar/menu-bar.jsx @@ -19,16 +19,16 @@ import Divider from '../divider/divider.jsx'; import SaveStatus from './save-status.jsx'; import ProjectWatcher from '../../containers/project-watcher.jsx'; import MenuBarMenu from './menu-bar-menu.jsx'; -import {MenuItem, MenuSection} from '../menu/menu.jsx'; +import {MenuItem} from '../menu/menu.jsx'; 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 ModeMenu from './mode-menu.jsx'; import {openTipsLibrary, openDebugModal} from '../../reducers/modals'; import {setPlayer} from '../../reducers/mode'; @@ -44,10 +44,7 @@ import { autoUpdateProject, getIsUpdating, getIsShowingProject, - manualUpdateProject, - requestNewProject, - remixProject, - saveProjectAsCopy + requestNewProject } from '../../reducers/project-state'; import { openAboutMenu, @@ -56,21 +53,9 @@ import { openAccountMenu, closeAccountMenu, accountMenuOpen, - openFileMenu, - closeFileMenu, - fileMenuOpen, - openEditMenu, - closeEditMenu, - editMenuOpen, openLoginMenu, closeLoginMenu, - loginMenuOpen, - openModeMenu, - closeModeMenu, - modeMenuOpen, - settingsMenuOpen, - openSettingsMenu, - closeSettingsMenu + loginMenuOpen } from '../../reducers/menus'; import collectMetadata from '../../lib/collect-metadata'; @@ -84,8 +69,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'; @@ -103,12 +86,17 @@ const ariaMessages = defineMessages({ tutorials: { id: 'gui.menuBar.tutorialsLibrary', defaultMessage: 'Tutorials', - description: 'accessibility text for the tutorials button' + description: 'ARIA text for the tutorials button' }, debug: { id: 'gui.menuBar.debug', defaultMessage: 'Debug', - description: 'accessibility text for the debug button' + description: 'ARIA text for the debug button' + }, + home: { + id: 'gui.menuBar.home', + defaultMessage: 'Home', + description: 'ARIA text for the home button' } }); @@ -186,9 +174,6 @@ class MenuBar extends React.Component { super(props); bindAll(this, [ 'handleClickNew', - 'handleClickRemix', - 'handleClickSave', - 'handleClickSaveAsCopy', 'handleClickSeeCommunity', 'handleClickShare', 'handleSetMode', @@ -197,6 +182,11 @@ class MenuBar extends React.Component { 'getSaveToComputerHandler', 'restoreOptionMessage' ]); + + this.settingsRef = React.createRef(); + this.fileRef = React.createRef(); + this.editRef = React.createRef(); + this.modeRef = React.createRef(); } componentDidMount () { document.addEventListener('keydown', this.handleKeyPress); @@ -213,23 +203,9 @@ class MenuBar extends React.Component { const readyToReplaceProject = this.props.confirmReadyToReplaceProject( this.props.intl.formatMessage(sharedMessages.replaceProjectWarning) ); - this.props.onRequestCloseFile(); if (readyToReplaceProject) { this.props.onClickNew(this.props.canSave && this.props.canCreateNew); } - this.props.onRequestCloseFile(); - } - handleClickRemix () { - this.props.onClickRemix(); - this.props.onRequestCloseFile(); - } - handleClickSave () { - this.props.onClickSave(); - this.props.onRequestCloseFile(); - } - handleClickSaveAsCopy () { - this.props.onClickSaveAsCopy(); - this.props.onRequestCloseFile(); } handleClickSeeCommunity (waitForUpdate) { if (this.props.shouldSaveBeforeTransition()) { @@ -285,10 +261,14 @@ class MenuBar extends React.Component { handleRestoreOption (restoreFun) { return () => { restoreFun(); - this.props.onRequestCloseEdit(); }; } 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(); @@ -297,7 +277,6 @@ class MenuBar extends React.Component { } getSaveToComputerHandler (downloadProjectCallback) { return () => { - this.props.onRequestCloseFile(); downloadProjectCallback(); if (this.props.onProjectTelemetryEvent) { const metadata = collectMetadata(this.props.vm, this.props.projectTitle, this.props.locale); @@ -385,20 +364,6 @@ class MenuBar extends React.Component { }; } render () { - const saveNowMessage = ( - - ); - const createCopyMessage = ( - - ); const remixMessage = ( ); - const newProjectMessage = ( - - ); const remixButton = (