diff --git a/package-lock.json b/package-lock.json index 804d6c9520..7d0b33a943 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34418,9 +34418,9 @@ } }, "node_modules/scratch-l10n": { - "version": "6.1.46", - "resolved": "https://registry.npmjs.org/scratch-l10n/-/scratch-l10n-6.1.46.tgz", - "integrity": "sha512-Tv8j4IbEHUPTyhn2gEWxkuekm+dMZ8vDxUfMsS1HKdD2vQLHqWP7aCQTMM9fCNoWBdYd1rqD2j9LC+j7cJu9/g==", + "version": "6.1.52", + "resolved": "https://registry.npmjs.org/scratch-l10n/-/scratch-l10n-6.1.52.tgz", + "integrity": "sha512-SeVyt/e23P3tDFGwOxM4czdXYpDpxQJxSaD9Wsea4IY0BCOgEhSWbAtGtvwqAoNO5JIFiVy/rqs34fKneRlhBg==", "license": "AGPL-3.0-only", "dependencies": { "@transifex/api": "7.1.5", @@ -34459,9 +34459,9 @@ } }, "node_modules/scratch-paint": { - "version": "4.1.39", - "resolved": "https://registry.npmjs.org/scratch-paint/-/scratch-paint-4.1.39.tgz", - "integrity": "sha512-/4nyoStsdnUpFglp6SFLbJhxqMCnw4KSAc7hmJZiIswP2zp/klDed2GViyvQlZxW+CzEF1RaIIVySnPPoLcFoA==", + "version": "4.1.44", + "resolved": "https://registry.npmjs.org/scratch-paint/-/scratch-paint-4.1.44.tgz", + "integrity": "sha512-pCLdcDngsEhiozw3fcZ/BXp3xBPGyqe3Uy8OOB/I50Lm7BOhRfBVnmsyeIccIY9AQWZJtMDdQTWcUps74ZA/6A==", "license": "AGPL-3.0-only", "dependencies": { "@scratch/paper": "^0.11.20221201200345", @@ -41060,8 +41060,8 @@ "redux-throttle": "0.1.1", "scratch-audio": "2.0.268", "scratch-blocks": "1.3.0", - "scratch-l10n": "6.1.46", - "scratch-paint": "4.1.39", + "scratch-l10n": "6.1.52", + "scratch-paint": "4.1.44", "scratch-render-fonts": "1.0.252", "scratch-storage": "5.0.10", "startaudiocontext": "1.2.1", @@ -41420,7 +41420,7 @@ "jsdoc": "3.6.11", "pngjs": "3.4.0", "scratch-blocks": "1.3.0", - "scratch-l10n": "6.1.46", + "scratch-l10n": "6.1.52", "scratch-render-fonts": "1.0.252", "scratch-semantic-release-config": "4.0.0", "scratch-webpack-configuration": "3.1.0", @@ -41641,4 +41641,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/scratch-gui/src/components/gui/gui.jsx b/packages/scratch-gui/src/components/gui/gui.jsx index fadab2bf94..9828331083 100644 --- a/packages/scratch-gui/src/components/gui/gui.jsx +++ b/packages/scratch-gui/src/components/gui/gui.jsx @@ -46,6 +46,7 @@ import DebugModal from '../debug-modal/debug-modal.jsx'; import {setPlatform} from '../../reducers/platform.js'; import {setTheme} from '../../reducers/settings.js'; import {PLATFORM} from '../../lib/platform.js'; +import {MenuRefProvider} from '../../contexts/menu-ref-context.jsx'; // Cache this value to only retrieve it once the first time. // Assume that it doesn't change for a session. @@ -272,47 +273,50 @@ const GUIComponent = props => { onRequestClose={onRequestCloseBackdropLibrary} /> ) : null} - {!menuBarHidden && } + {!menuBarHidden && + + + } { + 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 = ( {(this.props.canChangeColorMode || this.props.canChangeLanguage || this.props.canChangeTheme) && ()} + {(this.props.canManageFiles) && ()} + + {this.props.isTotallyNormal && ()} - {(this.props.canManageFiles) && ( - - - - - - - - - - {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) => ( - - - - )} - - - - )} - - - - - - - - {(handleRestore, {restorable, deletedItem}) => ( - - {this.restoreOptionMessage(deletedItem)} - - )} - - {(toggleTurboMode, {turboMode}) => ( - - {turboMode ? ( - - ) : ( - - )} - - )} - - - - - {this.props.isTotallyNormal && ( - - - - - - - - - {'✓'} - - {' '} - - - - - {'✓'} - - {' '} - - - - - - )} {this.props.canEditTitle ? ( @@ -711,6 +534,8 @@ class MenuBar extends React.Component { { aboutMenuOpen: aboutMenuOpen(state), accountMenuOpen: accountMenuOpen(state), currentLocale: state.locales.locale, - fileMenuOpen: fileMenuOpen(state), - editMenuOpen: editMenuOpen(state), isRtl: state.locales.isRtl, isUpdating: getIsUpdating(loadingState), isShowingProject: getIsShowingProject(loadingState), locale: state.locales.locale, loginMenuOpen: loginMenuOpen(state), - modeMenuOpen: modeMenuOpen(state), projectTitle: state.scratchGui.projectTitle, - settingsMenuOpen: settingsMenuOpen(state), username: ownProps.username ?? (user ? user.username : null), avatarBadge: user ? user.membership_avatar_badge : null, userIsEducator: permissions && permissions.educator, @@ -1059,22 +874,11 @@ const mapDispatchToProps = (dispatch, ownProps) => ({ onOpenDebugModal: () => dispatch(openDebugModal()), onClickAccount: () => dispatch(openAccountMenu()), onRequestCloseAccount: () => dispatch(closeAccountMenu()), - onClickFile: () => dispatch(openFileMenu()), - onRequestCloseFile: () => dispatch(closeFileMenu()), - onClickEdit: () => dispatch(openEditMenu()), - onRequestCloseEdit: () => dispatch(closeEditMenu()), + onClickNew: needSave => dispatch(requestNewProject(needSave)), onClickLogin: ownProps.onClickLogin ?? (() => dispatch(openLoginMenu())), onRequestCloseLogin: () => dispatch(closeLoginMenu()), - onClickMode: () => dispatch(openModeMenu()), - onRequestCloseMode: () => dispatch(closeModeMenu()), onRequestOpenAbout: () => dispatch(openAboutMenu()), onRequestCloseAbout: () => dispatch(closeAboutMenu()), - onClickSettings: () => dispatch(openSettingsMenu()), - onRequestCloseSettings: () => dispatch(closeSettingsMenu()), - onClickNew: needSave => dispatch(requestNewProject(needSave)), - onClickRemix: () => dispatch(remixProject()), - onClickSave: () => dispatch(manualUpdateProject()), - onClickSaveAsCopy: () => dispatch(saveProjectAsCopy()), onSeeCommunity: ownProps.onSeeCommunity ?? (() => dispatch(setPlayer(true))), onSetTimeTravelMode: mode => dispatch(setTimeTravel(mode)) }); diff --git a/packages/scratch-gui/src/components/menu-bar/mode-menu.jsx b/packages/scratch-gui/src/components/menu-bar/mode-menu.jsx new file mode 100644 index 0000000000..45828758a8 --- /dev/null +++ b/packages/scratch-gui/src/components/menu-bar/mode-menu.jsx @@ -0,0 +1,128 @@ +import React from 'react'; +import styles from './menu-bar.css'; +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import {connect} from 'react-redux'; + +import {FormattedMessage, defineMessage} from 'react-intl'; +import MenuBarMenu from './menu-bar-menu.jsx'; +import {MenuItem, MenuSection} from '../menu/menu.jsx'; +import useMenuNavigation from '../../hooks/use-menu-navigation.jsx'; + +import intlShape from '../../lib/intlShape.js'; +import propTypes from '../../lib/prop-types.js'; +const modeMenu = defineMessage({ + id: 'gui.aria.modeMenu', + defaultMessage: 'Mode menu', + description: 'ARIA label for mode menu' +}); + +const ModeMenu = props => { + const { + intl, + isRtl, + mode2020, + modeNow, + onSetMode, + menuRef + } = props; + + const normalRef = React.createRef(); + const caturdayRef = React.createRef(); + + const itemRefs = [ + normalRef, + caturdayRef + ]; + + const { + isExpanded, + handleOnOpen, + handleOnClose, + handleKeyPress, + handleKeyPressOpenMenu + } = useMenuNavigation({ + menuRef, + itemRefs, + depth: 1 + }); + + return ( + + + + + + + + + {'✓'} + + {' '} + + + + + {'✓'} + + {' '} + + + + + + ); +}; + +ModeMenu.propTypes = { + intl: intlShape.isRequired, + menuRef: propTypes.ref.isRequired, + onSetMode: PropTypes.func.isRequired, + modeNow: PropTypes.bool.isRequired, + mode2020: PropTypes.bool.isRequired, + isRtl: PropTypes.bool +}; + +const mapStateToProps = state => ({ + isRtl: state.locales.isRtl +}); + +export default connect( + mapStateToProps +)(ModeMenu); diff --git a/packages/scratch-gui/src/components/menu-bar/preference-menu.jsx b/packages/scratch-gui/src/components/menu-bar/preference-menu.jsx index b1a863e44c..48efbf0635 100644 --- a/packages/scratch-gui/src/components/menu-bar/preference-menu.jsx +++ b/packages/scratch-gui/src/components/menu-bar/preference-menu.jsx @@ -1,8 +1,9 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; -import React, {useMemo} from 'react'; +import React from 'react'; import {FormattedMessage} from 'react-intl'; import {connect} from 'react-redux'; +import useMenuNavigation from '../../hooks/use-menu-navigation.jsx'; import check from './check.svg'; import {MenuItem, Submenu} from '../menu/menu.jsx'; @@ -10,6 +11,7 @@ import {MenuItem, Submenu} from '../menu/menu.jsx'; import styles from './settings-menu.css'; import dropdownCaret from './dropdown-caret.svg'; +import propTypes from '../../lib/prop-types.js'; const intlMessageShape = PropTypes.shape({ defaultMessage: PropTypes.string, @@ -21,7 +23,12 @@ const PreferenceItem = props => { const item = props.item; return ( - + { - const itemKeys = useMemo(() => Object.keys(itemsMap), [itemsMap]); - const selectedItem = useMemo(() => itemsMap[selectedItemKey], [itemsMap, selectedItemKey]); +const PreferenceMenu = props => { + const { + itemsMap, + onChange, + defaultMenuIconSrc, + submenuLabel, + selectedItemKey, + isRtl, + menuRef, + ariaLabel + } = props; + + const itemRefs = Object.keys(itemsMap).map(() => React.createRef()); + + const itemKeys = Object.keys(itemsMap); + const selectedItem = itemsMap[selectedItemKey]; + + const { + isExpanded, + handleKeyPress, + handleKeyPressOpenMenu, + handleOnOpen + } = useMenuNavigation({ + menuRef, + itemRefs, + depth: 2 + }); + return ( - + - {itemKeys.map(itemKey => ( + {itemKeys.map((itemKey, index) => ( onChange(itemKey)} item={itemsMap[itemKey]} + itemRef={itemRefs[index]} />) )} @@ -91,14 +124,13 @@ const PreferenceMenu = ({ }; PreferenceMenu.propTypes = { + ariaLabel: PropTypes.string, + menuRef: propTypes.ref.isRequired, itemsMap: PropTypes.objectOf(PropTypes.shape({ icon: PropTypes.string, label: intlMessageShape.isRequired })).isRequired, - open: PropTypes.bool, onChange: PropTypes.func, - onRequestCloseSettings: PropTypes.func, - onRequestOpen: PropTypes.func, defaultMenuIconSrc: PropTypes.string, submenuLabel: intlMessageShape.isRequired, selectedItemKey: PropTypes.string, diff --git a/packages/scratch-gui/src/components/menu-bar/settings-menu.jsx b/packages/scratch-gui/src/components/menu-bar/settings-menu.jsx index 2728762707..53d3beb9ab 100644 --- a/packages/scratch-gui/src/components/menu-bar/settings-menu.jsx +++ b/packages/scratch-gui/src/components/menu-bar/settings-menu.jsx @@ -1,8 +1,9 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; -import React, {useMemo} from 'react'; -import {FormattedMessage} from 'react-intl'; +import React, {useRef, useMemo} from 'react'; +import {FormattedMessage, defineMessages} from 'react-intl'; import {connect} from 'react-redux'; +import useMenuNavigation from '../../hooks/use-menu-navigation.jsx'; import LanguageMenu from './language-menu.jsx'; import MenuBarMenu from './menu-bar-menu.jsx'; @@ -20,29 +21,46 @@ import styles from './settings-menu.css'; import dropdownCaret from './dropdown-caret.svg'; import settingsIcon from './icon--settings.svg'; + import themeIcon from '../../lib/assets/icon--theme.svg'; -import {colorModeMenuOpen, themeMenuOpen, openColorModeMenu, openThemeMenu} from '../../reducers/menus.js'; +import intlShape from '../../lib/intlShape.js'; +import propTypes from '../../lib/prop-types.js'; + +const ariaMessages = defineMessages({ + settingsMenu: { + id: 'gui.aria.settingsMenu', + defaultMessage: 'Settings menu', + description: 'ARIA label for settings menu' + }, + themeMenu: { + id: 'gui.aria.themeMenu', + defaultMessage: 'Theme menu', + description: 'ARIA label for theme menu' + }, + colorMenu: { + id: 'gui.aria.colorMenu', + defaultMessage: 'Color menu', + description: 'ARIA label for color menu' + } +}); const enabledColorModes = [DEFAULT_MODE, HIGH_CONTRAST_MODE]; -const SettingsMenu = ({ - canChangeLanguage, - canChangeColorMode, - canChangeTheme, - hasActiveMembership, - isRtl, - isColorModeMenuOpen, - isThemeMenuOpen, - activeColorMode, - onChangeColorMode, - onRequestOpenColorMode, - onRequestOpenTheme, - activeTheme, - onChangeTheme, - onRequestClose, - onRequestOpen, - settingsMenuOpen -}) => { +const SettingsMenu = props => { + const { + menuRef, + canChangeLanguage, + canChangeColorMode, + canChangeTheme, + hasActiveMembership, + intl, + isRtl, + activeColorMode, + onChangeColorMode, + activeTheme, + onChangeTheme + } = props; + const enabledColorModesMap = useMemo(() => Object.keys(colorModeMap).reduce((acc, colorMode) => { if (enabledColorModes.includes(colorMode)) { acc[colorMode] = colorModeMap[colorMode]; @@ -58,111 +76,124 @@ const SettingsMenu = ({ }, {}), [hasActiveMembership]); const availableThemesLength = useMemo(() => Object.keys(availableThemesMap).length, [availableThemesMap]); - return ( - - 1 ? [themeRef] : []), + ...(canChangeColorMode ? [colorRef] : []) + ]; + + const { + isExpanded, + handleOnOpen, + handleOnClose, + handleKeyPress + } = useMenuNavigation({ + menuRef, + itemRefs, + depth: 1 + }); + + return ( + + + - - - - - - - {canChangeLanguage && } - {canChangeTheme && - // TODO: Consider always showing the theme menu, even if there is a single available theme - availableThemesLength > 1 && - } - {canChangeColorMode && + + + + {canChangeLanguage && } + {canChangeTheme && + // TODO: Consider always showing the theme menu, even if there is a single available theme + availableThemesLength > 1 && + } - - - - ); + {canChangeColorMode && } + + + ); }; SettingsMenu.propTypes = { - canChangeLanguage: PropTypes.bool, - canChangeColorMode: PropTypes.bool, - canChangeTheme: PropTypes.bool, - hasActiveMembership: PropTypes.bool, - isRtl: PropTypes.bool, + menuRef: propTypes.ref.isRequired, + intl: intlShape.isRequired, + canChangeLanguage: PropTypes.bool.isRequired, + canChangeColorMode: PropTypes.bool.isRequired, + canChangeTheme: PropTypes.bool.isRequired, + hasActiveMembership: PropTypes.bool.isRequired, + isRtl: PropTypes.bool.isRequired, activeColorMode: PropTypes.string, onChangeColorMode: PropTypes.func, - onRequestOpenColorMode: PropTypes.func, - isColorModeMenuOpen: PropTypes.bool, activeTheme: PropTypes.string, - onChangeTheme: PropTypes.func, - onRequestOpenTheme: PropTypes.func, - isThemeMenuOpen: PropTypes.bool, - onRequestClose: PropTypes.func, - onRequestOpen: PropTypes.func, - settingsMenuOpen: PropTypes.bool + onChangeTheme: PropTypes.func }; const mapStateToProps = state => ({ activeColorMode: state.scratchGui.settings.colorMode, - activeTheme: state.scratchGui.settings.theme, - isColorModeMenuOpen: colorModeMenuOpen(state), - isThemeMenuOpen: themeMenuOpen(state) + activeTheme: state.scratchGui.settings.theme }); const mapDispatchToProps = (dispatch, ownProps) => ({ - onRequestOpenColorMode: () => { - dispatch(openColorModeMenu()); - }, - onRequestOpenTheme: () => { - dispatch(openThemeMenu()); - }, onChangeColorMode: colorMode => { dispatch(setColorMode(colorMode)); - ownProps.onRequestClose(); + ownProps.onClose(); persistColorMode(colorMode); }, onChangeTheme: theme => { dispatch(setTheme(theme)); - ownProps.onRequestClose(); + ownProps.onClose(); persistTheme(theme); } }); diff --git a/packages/scratch-gui/src/components/menu/menu.jsx b/packages/scratch-gui/src/components/menu/menu.jsx index 5be46a32e3..34589998c7 100644 --- a/packages/scratch-gui/src/components/menu/menu.jsx +++ b/packages/scratch-gui/src/components/menu/menu.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import styles from './menu.css'; +import propTypes from '../../lib/prop-types'; const MenuComponent = ({ className = '', @@ -32,7 +33,6 @@ MenuComponent.propTypes = { place: PropTypes.oneOf(['left', 'right']) }; - const Submenu = ({children, className, place, ...props}) => ( ( {children} ); MenuItem.propTypes = { + itemRef: propTypes.ref, + ariaLabel: PropTypes.string, + ariaRole: PropTypes.string, children: PropTypes.node, className: PropTypes.string, expanded: PropTypes.bool, - onClick: PropTypes.func + isSelected: PropTypes.bool, + isDisabled: PropTypes.bool, + onClick: PropTypes.func, + onParentKeyPress: PropTypes.func }; diff --git a/packages/scratch-gui/src/contexts/menu-ref-context.jsx b/packages/scratch-gui/src/contexts/menu-ref-context.jsx new file mode 100644 index 0000000000..75be03f24d --- /dev/null +++ b/packages/scratch-gui/src/contexts/menu-ref-context.jsx @@ -0,0 +1,78 @@ +import React, {useCallback, useMemo, useState} from 'react'; +import PropTypes from 'prop-types'; + +export const MenuRefContext = React.createContext(null); + +export const MenuRefProvider = ({children}) => { + const [refStack, setRefStack] = useState([]); + + const cut = useCallback(ref => { + setRefStack(prev => { + const index = prev.indexOf(ref); + if (index === -1) return prev; + return prev.slice(0, index); + }); + }, []); + + const push = useCallback((ref, depth) => { + setRefStack(prev => { + let next = prev; + + if (depth <= prev.length) { + const cutRef = prev[depth - 1]; + const index = prev.indexOf(cutRef); + if (index !== -1) { + next = prev.slice(0, index); + } + } + + return [...next, ref]; + }); + }, []); + + + const pop = useCallback(() => { + setRefStack(prev => prev.slice(0, prev.length - 1)); + }, []); + + const clear = useCallback(() => { + setRefStack([]); + }, []); + + const bottomMenu = useMemo(() => (refStack.length > 0 ? refStack[0] : null), [refStack]); + + const isTopMenu = useCallback(ref => (refStack.length > 0 && + refStack[refStack.length - 1] === ref), [refStack]); + + const isOpenMenu = useCallback(ref => (refStack.includes(ref)), [refStack]); + + const value = useMemo(() => ({ + refStack, + push, + pop, + cut, + clear, + isTopMenu, + isOpenMenu, + bottomMenu + }), [ + refStack, + push, + pop, + cut, + clear, + isTopMenu, + isOpenMenu, + bottomMenu + ]); + + return ( + + {children} + + ); +}; + +MenuRefProvider.propTypes = { + children: PropTypes.node +}; diff --git a/packages/scratch-gui/src/hooks/use-menu-navigation.jsx b/packages/scratch-gui/src/hooks/use-menu-navigation.jsx new file mode 100644 index 0000000000..ee59f6055c --- /dev/null +++ b/packages/scratch-gui/src/hooks/use-menu-navigation.jsx @@ -0,0 +1,123 @@ +import {useCallback, useContext, useState, useEffect} from 'react'; +import {MenuRefContext} from '../contexts/menu-ref-context'; + +/** + * Provides keyboard navigation and focus management logic for menu components. + * + * This hook encapsulates shared menu behavior such as: + * - opening and closing menus + * - moving focus between menu items with arrow keys + * - handling Escape, Enter, and Tab behavior + * - coordinating open menus via MenuRefContext + * @param {object} params + * Parameters object + * @param {{ current: HTMLElement | null }} params.menuRef + * Ref to the menu trigger or container element. + * @param {Array<{ current: HTMLElement | null }>} params.itemRefs + * Refs for each focusable menu item, in display order. + * @param {number} params.depth + * Nesting depth of the menu (1 = top-level menu). + * @param {number} params.defaultIndexOnOpen + * Default menu item index to open to + * @returns {object} An object containing the focused index, menu state, and keyboard handlers: + * - focusedIndex: number — Index of the currently focused menu item. + * - isExpanded: function() — Returns true if the menu is expanded. + * - handleKeyPress: function(KeyboardEvent) — Handler for key presses on the menu. + * - handleKeyPressOpenMenu: function(KeyboardEvent) — Handler for key presses when the menu is open. + * - handleOnOpen: function() — Function to open the menu. + * - handleOnClose: function() — Function to close the menu. + */ +export default function useMenuNavigation ({ + menuRef, + itemRefs, + depth, + defaultIndexOnOpen = 0 +}) { + const menuContext = useContext(MenuRefContext); + const [focusedIndex, setFocusedIndex] = useState(-1); + + const refocusRef = useCallback(ref => { + if (ref?.current) { + ref.current.focus(); + } + }, []); + + useEffect(() => { + if (focusedIndex >= 0) { + refocusRef(itemRefs[focusedIndex]); + } + }, [focusedIndex]); + + const isExpanded = useCallback( + () => menuContext.isOpenMenu(menuRef), + [menuContext, menuRef] + ); + + const handleOnOpen = useCallback(() => { + if (menuContext.isOpenMenu(menuRef)) return; + + menuContext.push(menuRef, depth); + setFocusedIndex(defaultIndexOnOpen); + }, [menuContext, menuRef, depth]); + + const handleOnClose = useCallback(() => { + setFocusedIndex(-1); + menuContext.cut(menuRef); + refocusRef(menuRef); + }, [menuContext, menuRef, refocusRef]); + + const handleMove = useCallback(direction => { + const nextIndex = + (focusedIndex + direction + itemRefs.length) % + itemRefs.length; + + setFocusedIndex(nextIndex); + }, [focusedIndex, itemRefs, refocusRef]); + + const handleKeyPressOpenMenu = useCallback(e => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + handleMove(1); + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + handleMove(-1); + } + if (e.key === 'ArrowLeft' || e.key === 'Escape') { + e.preventDefault(); + handleOnClose(); + } + }, [handleMove, handleOnClose, menuContext]); + + const handleKeyPress = useCallback(e => { + if (isExpanded() && depth === 1 && e.key === 'Tab') { + handleOnClose(); + menuContext.clear(); + } + + if (menuContext.isTopMenu(menuRef)) { + handleKeyPressOpenMenu(e); + } else if (!isExpanded() && (e.key === ' ' || (e.key === 'ArrowRight' && depth !== 1))) { + e.preventDefault(); + handleOnOpen(); + } + }, [ + depth, + menuContext, + menuRef, + isExpanded, + handleKeyPressOpenMenu, + handleOnOpen, + handleOnClose + ]); + + return { + focusedIndex, + isExpanded, + handleKeyPress, + handleKeyPressOpenMenu, + handleOnOpen, + handleOnClose, + refocusRef + }; +} diff --git a/packages/scratch-gui/src/lib/prop-types.js b/packages/scratch-gui/src/lib/prop-types.js new file mode 100644 index 0000000000..4b65abd480 --- /dev/null +++ b/packages/scratch-gui/src/lib/prop-types.js @@ -0,0 +1,9 @@ +import PropTypes from 'prop-types'; + +const propTypes = { + ref: PropTypes.shape({ + current: PropTypes.instanceOf(Element) + }) +}; + +export default propTypes; diff --git a/packages/scratch-gui/src/lib/sb-file-uploader-hoc.jsx b/packages/scratch-gui/src/lib/sb-file-uploader-hoc.jsx index 96872a7a53..2dff58329c 100644 --- a/packages/scratch-gui/src/lib/sb-file-uploader-hoc.jsx +++ b/packages/scratch-gui/src/lib/sb-file-uploader-hoc.jsx @@ -19,9 +19,6 @@ import { openLoadingProject, closeLoadingProject } from '../reducers/modals'; -import { - closeFileMenu -} from '../reducers/menus'; import {getProjectTitleFromFilename} from './sb-file-uploader-utils'; const messages = defineMessages({ @@ -116,7 +113,6 @@ const SBFileUploaderHOC = function (WrappedComponent) { // skips ahead to step 7 this.removeFileObjects(); } - this.props.closeFileMenu(); } } // step 4 is below, in mapDispatchToProps @@ -176,7 +172,6 @@ const SBFileUploaderHOC = function (WrappedComponent) { const { cancelFileUpload, - closeFileMenu: closeFileMenuProp, isLoadingUpload, isShowingWithoutId, loadingState, @@ -206,7 +201,6 @@ const SBFileUploaderHOC = function (WrappedComponent) { SBFileUploaderComponent.propTypes = { canSave: PropTypes.bool, cancelFileUpload: PropTypes.func, - closeFileMenu: PropTypes.func, intl: intlShape.isRequired, isLoadingUpload: PropTypes.bool, isShowingWithoutId: PropTypes.bool, @@ -238,13 +232,11 @@ const SBFileUploaderHOC = function (WrappedComponent) { }; const mapDispatchToProps = (dispatch, ownProps) => ({ cancelFileUpload: loadingState => dispatch(onLoadedProject(loadingState, false, false)), - closeFileMenu: () => dispatch(closeFileMenu()), // transition project state from loading to regular, and close // loading screen and file menu onLoadingFinished: (loadingState, success) => { dispatch(onLoadedProject(loadingState, ownProps.canSave, success)); dispatch(closeLoadingProject()); - dispatch(closeFileMenu()); }, // show project loading screen onLoadingStarted: () => dispatch(openLoadingProject()), diff --git a/packages/scratch-gui/src/reducers/menus.js b/packages/scratch-gui/src/reducers/menus.js index 8e62120337..2d75a9fdfc 100644 --- a/packages/scratch-gui/src/reducers/menus.js +++ b/packages/scratch-gui/src/reducers/menus.js @@ -3,14 +3,7 @@ const CLOSE_MENU = 'scratch-gui/menus/CLOSE_MENU'; const MENU_ABOUT = 'aboutMenu'; const MENU_ACCOUNT = 'accountMenu'; -const MENU_EDIT = 'editMenu'; -const MENU_FILE = 'fileMenu'; -const MENU_LANGUAGE = 'languageMenu'; const MENU_LOGIN = 'loginMenu'; -const MENU_MODE = 'modeMenu'; -const MENU_SETTINGS = 'settingsMenu'; -const MENU_COLOR_MODE = 'colorModeMenu'; -const MENU_THEME = 'themeMenu'; class Menu { constructor (id) { @@ -49,16 +42,6 @@ class Menu { // Structure of nested menus, used for collapsing submenus logic. const rootMenu = new Menu('root') - .addChild( - new Menu(MENU_SETTINGS) - .addChild(new Menu(MENU_LANGUAGE)) - .addChild(new Menu(MENU_COLOR_MODE)) - .addChild(new Menu(MENU_THEME)) - ) - .addChild(new Menu(MENU_FILE)) - .addChild(new Menu(MENU_EDIT)) - .addChild(new Menu(MENU_MODE)) - .addChild(new Menu(MENU_SETTINGS)) .addChild(new Menu(MENU_LOGIN)) .addChild(new Menu(MENU_ACCOUNT)) .addChild(new Menu(MENU_ABOUT)); @@ -66,14 +49,7 @@ const rootMenu = new Menu('root') const initialState = { [MENU_ABOUT]: false, [MENU_ACCOUNT]: false, - [MENU_EDIT]: false, - [MENU_FILE]: false, - [MENU_LANGUAGE]: false, - [MENU_LOGIN]: false, - [MENU_MODE]: false, - [MENU_SETTINGS]: false, - [MENU_COLOR_MODE]: false, - [MENU_THEME]: false + [MENU_LOGIN]: false }; const reducer = function (state, action) { @@ -121,38 +97,10 @@ const openAccountMenu = () => openMenu(MENU_ACCOUNT); const closeAccountMenu = () => closeMenu(MENU_ACCOUNT); const accountMenuOpen = state => state.scratchGui.menus[MENU_ACCOUNT]; -const openEditMenu = () => openMenu(MENU_EDIT); -const closeEditMenu = () => closeMenu(MENU_EDIT); -const editMenuOpen = state => state.scratchGui.menus[MENU_EDIT]; - -const openFileMenu = () => openMenu(MENU_FILE); -const closeFileMenu = () => closeMenu(MENU_FILE); -const fileMenuOpen = state => state.scratchGui.menus[MENU_FILE]; - -const openLanguageMenu = () => openMenu(MENU_LANGUAGE); -const closeLanguageMenu = () => closeMenu(MENU_LANGUAGE); -const languageMenuOpen = state => state.scratchGui.menus[MENU_LANGUAGE]; - const openLoginMenu = () => openMenu(MENU_LOGIN); const closeLoginMenu = () => closeMenu(MENU_LOGIN); const loginMenuOpen = state => state.scratchGui.menus[MENU_LOGIN]; -const openModeMenu = () => openMenu(MENU_MODE); -const closeModeMenu = () => closeMenu(MENU_MODE); -const modeMenuOpen = state => state.scratchGui.menus[MENU_MODE]; - -const openSettingsMenu = () => openMenu(MENU_SETTINGS); -const closeSettingsMenu = () => closeMenu(MENU_SETTINGS); -const settingsMenuOpen = state => state.scratchGui.menus[MENU_SETTINGS]; - -const openColorModeMenu = () => openMenu(MENU_COLOR_MODE); -const closeColorModeMenu = () => closeMenu(MENU_COLOR_MODE); -const colorModeMenuOpen = state => state.scratchGui.menus[MENU_COLOR_MODE]; - -const openThemeMenu = () => openMenu(MENU_THEME); -const closeThemeMenu = () => closeMenu(MENU_THEME); -const themeMenuOpen = state => state.scratchGui.menus[MENU_THEME]; - export { reducer as default, initialState as menuInitialState, @@ -162,28 +110,7 @@ export { openAccountMenu, closeAccountMenu, accountMenuOpen, - openEditMenu, - closeEditMenu, - editMenuOpen, - openFileMenu, - closeFileMenu, - fileMenuOpen, - openLanguageMenu, - closeLanguageMenu, - languageMenuOpen, openLoginMenu, closeLoginMenu, - loginMenuOpen, - openModeMenu, - closeModeMenu, - modeMenuOpen, - openSettingsMenu, - closeSettingsMenu, - settingsMenuOpen, - openColorModeMenu, - closeColorModeMenu, - colorModeMenuOpen, - openThemeMenu, - closeThemeMenu, - themeMenuOpen + loginMenuOpen };