{ + const { + menuRef, + isRtl, + onRestoreOption, + restoreOptionMessage + } = props; + const intl = useIntl(); + + const restoreRef = useRef(null); + const turboRef = useRef(null); + + const itemRefs = [restoreRef, turboRef]; + + const { + isExpanded, + handleKeyPress, + handleKeyPressOpenMenu, + handleOnOpen, + handleOnClose + } = useMenuNavigation({ + menuRef, + itemRefs, + depth: 1 + }); + + return ( + + ); +}; + +EditMenu.propTypes = { + 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..094be3f874 --- /dev/null +++ b/packages/scratch-gui/src/components/menu-bar/file-menu.jsx @@ -0,0 +1,221 @@ +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 {useIntl, 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 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 { + isRtl, + menuRef, + canSave, + canCreateCopy, + canRemix, + onClickNew, + onClickSave, + onClickSaveAsCopy, + onClickRemix, + onStartSelectingFileUpload, + getSaveToComputerHandler, + remixMessage + } = props; + const intl = useIntl(); + + 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 ( + + ); +}; + +FileMenu.propTypes = { + menuRef: propTypes.ref.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..a31752e6aa 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,148 @@ 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 {useIntl, 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 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 { + currentLocale, + menuRef, + isRtl, + onChangeLanguage + } = props; + const intl = useIntl(); + + const itemRefs = React.useMemo(() => Object.keys(locales).map(() => React.createRef()), []); + const 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.current = 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, index) => { + const isSelected = currentLocale === locale; + + return ( onChangeLanguage(locale)} + itemRef={itemRefs[index]} + onParentKeyPress={handleKeyPressOpenMenu} + isSelected={isSelected} + > + + {locales[locale].name} + ); + }) + } + + + ); +}; LanguageMenu.propTypes = { + menuRef: propTypes.ref.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.css b/packages/scratch-gui/src/components/menu-bar/menu-bar.css index 68063fcf47..7aa3610348 100644 --- a/packages/scratch-gui/src/components/menu-bar/menu-bar.css +++ b/packages/scratch-gui/src/components/menu-bar/menu-bar.css @@ -62,6 +62,12 @@ align-items: center; white-space: nowrap; height: $menu-bar-height; + + background: none; + border: none; + font: inherit; + text-align: inherit; + cursor: pointer; } .menu-bar-item.hoverable { 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 79b865abb4..0e0bb9b0bc 100644 --- a/packages/scratch-gui/src/components/menu-bar/menu-bar.jsx +++ b/packages/scratch-gui/src/components/menu-bar/menu-bar.jsx @@ -1,7 +1,7 @@ import classNames from 'classnames'; import {connect} from 'react-redux'; import {compose} from 'redux'; -import {defineMessages, FormattedMessage, injectIntl} from 'react-intl'; +import {defineMessages, FormattedMessage, injectIntl, useIntl} from 'react-intl'; import intlShape from '../../lib/intlShape.js'; import PropTypes from 'prop-types'; import bindAll from 'lodash.bindall'; @@ -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,22 @@ 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' + }, + about: { + id: 'gui.menuBar.about', + defaultMessage: 'About', + description: 'ARIA text for the about button' } }); @@ -168,14 +161,17 @@ MenuItemTooltip.propTypes = { isRtl: PropTypes.bool }; -const AboutButton = props => ( - @@ -924,9 +754,7 @@ MenuBar.propTypes = { className: PropTypes.string, confirmReadyToReplaceProject: PropTypes.func, currentLocale: PropTypes.string.isRequired, - editMenuOpen: PropTypes.bool, enableCommunity: PropTypes.bool, - fileMenuOpen: PropTypes.bool, hasActiveMembership: PropTypes.bool, intl: intlShape, isRtl: PropTypes.bool, @@ -941,7 +769,6 @@ MenuBar.propTypes = { mode1990: PropTypes.bool, mode2020: PropTypes.bool, mode220022BC: PropTypes.bool, - modeMenuOpen: PropTypes.bool, modeNow: PropTypes.bool, onClickAbout: PropTypes.oneOfType([ PropTypes.func, // button mode: call this callback when the About button is clicked @@ -970,11 +797,7 @@ MenuBar.propTypes = { onProjectTelemetryEvent: PropTypes.func, onRequestCloseAbout: PropTypes.func, onRequestCloseAccount: PropTypes.func, - onRequestCloseEdit: PropTypes.func, - onRequestCloseFile: PropTypes.func, onRequestCloseLogin: PropTypes.func, - onRequestCloseMode: PropTypes.func, - onRequestCloseSettings: PropTypes.func, onRequestOpenAbout: PropTypes.func, onSeeCommunity: PropTypes.func, onSetTimeTravelMode: PropTypes.func, @@ -984,7 +807,6 @@ MenuBar.propTypes = { platform: PropTypes.oneOf(Object.keys(PLATFORM)), projectTitle: PropTypes.string, renderLogin: PropTypes.func, - settingsMenuOpen: PropTypes.bool, shouldSaveBeforeTransition: PropTypes.func, showComingSoon: PropTypes.bool, username: PropTypes.string, @@ -1011,16 +833,12 @@ const mapStateToProps = (state, ownProps) => { 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, @@ -1060,22 +878,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..30e3ac0aa8 --- /dev/null +++ b/packages/scratch-gui/src/components/menu-bar/mode-menu.jsx @@ -0,0 +1,131 @@ +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 {useIntl, 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 propTypes from '../../lib/prop-types.js'; + +const EditorModes = { + NOW: 'NOW', + MODE_2020: '2020' +}; + +const modeMenu = defineMessage({ + id: 'gui.aria.modeMenu', + defaultMessage: 'Mode menu', + description: 'ARIA label for mode menu' +}); + +const ModeMenu = props => { + const { + isRtl, + mode2020, + modeNow, + onSetMode, + menuRef + } = props; + const intl = useIntl(); + + const normalRef = useRef(null); + const caturdayRef = useRef(null); + + const itemRefs = [ + normalRef, + caturdayRef + ]; + + const { + isExpanded, + handleOnOpen, + handleOnClose, + handleKeyPress, + handleKeyPressOpenMenu + } = useMenuNavigation({ + menuRef, + itemRefs, + depth: 1 + }); + + return ( + + ); +}; + +ModeMenu.propTypes = { + 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..280c9558c8 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 +123,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.css b/packages/scratch-gui/src/components/menu-bar/settings-menu.css index 7d09671776..ba4e7865cb 100644 --- a/packages/scratch-gui/src/components/menu-bar/settings-menu.css +++ b/packages/scratch-gui/src/components/menu-bar/settings-menu.css @@ -10,7 +10,17 @@ .option { display: flex; align-items: center; - gap: .5rem; + gap: 0.5rem; + + width: 100%; + padding: 0; + + background: none; + border: none; + font: inherit; + text-align: inherit; + cursor: pointer; + color: inherit; } .check { 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..3d55cd7538 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 {useIntl, 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,45 @@ 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 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, + isRtl, + activeColorMode, + onChangeColorMode, + activeTheme, + onChangeTheme + } = props; + const intl = useIntl(); + const enabledColorModesMap = useMemo(() => Object.keys(colorModeMap).reduce((acc, colorMode) => { if (enabledColorModes.includes(colorMode)) { acc[colorMode] = colorModeMap[colorMode]; @@ -58,111 +75,121 @@ 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 (
- ); + {canChangeColorMode && } + + + ); }; SettingsMenu.propTypes = { - canChangeLanguage: PropTypes.bool, - canChangeColorMode: PropTypes.bool, - canChangeTheme: PropTypes.bool, - hasActiveMembership: PropTypes.bool, - isRtl: PropTypes.bool, + menuRef: propTypes.ref.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(); persistColorMode(colorMode); }, onChangeTheme: theme => { dispatch(setTheme(theme)); - ownProps.onRequestClose(); persistTheme(theme); } }); diff --git a/packages/scratch-gui/src/components/menu/menu.jsx b/packages/scratch-gui/src/components/menu/menu.jsx index bffe47752b..6bbcdf27a3 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/containers/language-selector.jsx b/packages/scratch-gui/src/containers/language-selector.jsx index 0cb9f08846..b39ace7cd1 100644 --- a/packages/scratch-gui/src/containers/language-selector.jsx +++ b/packages/scratch-gui/src/containers/language-selector.jsx @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import React from 'react'; import {connect} from 'react-redux'; import {selectLocale} from '../reducers/locales'; -import {closeLanguageMenu} from '../reducers/menus'; import LanguageSelectorComponent from '../components/language-selector/language-selector.jsx'; @@ -56,7 +55,6 @@ const mapStateToProps = state => ({ const mapDispatchToProps = dispatch => ({ onChangeLanguage: locale => { dispatch(selectLocale(locale)); - dispatch(closeLanguageMenu()); } }); 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..8ec6168ce2 --- /dev/null +++ b/packages/scratch-gui/src/contexts/menu-ref-context.jsx @@ -0,0 +1,102 @@ +import React, {useCallback, useMemo, useState} from 'react'; +import PropTypes from 'prop-types'; + +export const MenuRefContext = React.createContext(null); + +/** + * This provider manages references to menu components in order to ensure + * sensible behavior for handling menu opening and closing logic + * @param {object} props + * Provider props. + * @param {React.ReactNode} props.children + * Child components that use the logic of the provider. + * @returns {React.ReactNode} + * A MenuRefContext provider exposing: + * - refStack: Array of currently opened menus one after the other + * - openInnerMenu(ref, depth) + * Adds menu at said depth, closing any inner menus that followed previously. + * - closeMenuByRef(ref) + * Closes the specified menu and all menus nested in it. + * - closeInnerMenu() + * Closes only the current innermost menu. + * - closeAllMenus() + * Closes all open menus. + * - isOpenMenu(ref) + * Returns if the given menu is currently open. + * - isInnermostMenu(ref) + * Returns if the given menu is currently the innermost one. + * - outermostMenu + * Returns ref of the outermost open menu. + */ +export const MenuRefProvider = ({children}) => { + const [refStack, setRefStack] = useState([]); + + const closeMenuByRef = useCallback(ref => { + setRefStack(prev => { + const index = prev.indexOf(ref); + if (index === -1) return prev; + return prev.slice(0, index); + }); + }, []); + + const openInnerMenu = 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 closeInnerMenu = useCallback(() => { + setRefStack(prev => prev.slice(0, prev.length - 1)); + }, []); + + const closeAllMenus = useCallback(() => { + setRefStack([]); + }, []); + + const outermostMenu = useMemo(() => (refStack.length > 0 ? refStack[0] : null), [refStack]); + + const isInnermostMenu = useCallback(ref => (refStack.length > 0 && + refStack[refStack.length - 1] === ref), [refStack]); + + const isOpenMenu = useCallback(ref => (refStack.includes(ref)), [refStack]); + + const value = useMemo(() => ({ + refStack, + openInnerMenu, + closeInnerMenu, + closeMenuByRef, + closeAllMenus, + isInnermostMenu, + isOpenMenu, + outermostMenu + }), [ + refStack, + openInnerMenu, + closeInnerMenu, + closeMenuByRef, + closeAllMenus, + isInnermostMenu, + isOpenMenu, + outermostMenu + ]); + + 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..9325cda084 --- /dev/null +++ b/packages/scratch-gui/src/hooks/use-menu-navigation.jsx @@ -0,0 +1,137 @@ +import {useCallback, useContext, useState, useEffect} from 'react'; +import {MenuRefContext} from '../contexts/menu-ref-context'; + +const KEY = { + ARROW_UP: 'ArrowUp', + ARROW_DOWN: 'ArrowDown', + ARROW_LEFT: 'ArrowLeft', + ARROW_RIGHT: 'ArrowRight', + ESCAPE: 'Escape', + TAB: 'Tab', + SPACE: ' ' +}; + +/** + * 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.openInnerMenu(menuRef, depth); + setFocusedIndex(defaultIndexOnOpen); + }, [menuContext, menuRef, depth]); + + const handleOnClose = useCallback(() => { + setFocusedIndex(-1); + menuContext.closeMenuByRef(menuRef); + refocusRef(menuRef); + }, [menuContext, menuRef, refocusRef]); + + const handleMove = useCallback(direction => { + // Calculate the next focused menu item index based on the direction. + // Wraps around the list so that moving past the first or last item + // loops to the other end, preventing out-of-bounds errors. + const nextIndex = + (focusedIndex + direction + itemRefs.length) % + itemRefs.length; + + setFocusedIndex(nextIndex); + }, [focusedIndex, itemRefs, refocusRef]); + + const handleKeyPressOpenMenu = useCallback(e => { + // Logic for vertical menus, will need to change when implementing for vertical + if (e.key === KEY.ARROW_DOWN) { + e.preventDefault(); + handleMove(1); + } + if (e.key === KEY.ARROW_UP) { + e.preventDefault(); + handleMove(-1); + } + if (e.key === KEY.ARROW_LEFT || e.key === KEY.ESCAPE) { + e.preventDefault(); + handleOnClose(); + } + }, [handleMove, handleOnClose, menuContext]); + + const handleKeyPress = useCallback(e => { + if (isExpanded() && depth === 1 && e.key === KEY.TAB) { + handleOnClose(); + menuContext.closeAllMenus(); + } + + if (menuContext.isInnermostMenu(menuRef)) { + handleKeyPressOpenMenu(e); + } else if (!isExpanded() && (e.key === KEY.SPACE || (e.key === KEY.ARROW_RIGHT && 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 }; diff --git a/packages/scratch-gui/test/unit/components/menu-bar.test.jsx b/packages/scratch-gui/test/unit/components/menu-bar.test.jsx index e679b63290..f683f9052e 100644 --- a/packages/scratch-gui/test/unit/components/menu-bar.test.jsx +++ b/packages/scratch-gui/test/unit/components/menu-bar.test.jsx @@ -11,6 +11,7 @@ import {PLATFORM} from '../../../src/lib/platform'; import configureStore from 'redux-mock-store'; import {Provider} from 'react-redux'; import VM from '@scratch/scratch-vm'; +import {MenuRefProvider} from '../../../src/contexts/menu-ref-context.jsx'; describe('MenuBar Component', () => { const store = configureStore()({ @@ -37,19 +38,23 @@ describe('MenuBar Component', () => { }); const getComponent = function (props = {}) { - return ; + return ( + + + + ); }; test('menu bar with no About handler has no About button', () => { const {container} = renderWithIntl(getComponent()); - const button = container.querySelector('button'); + const button = container.querySelector('button[aria-label="About"]'); expect(button).toBeFalsy(); }); test('menu bar with an About handler has an About button', () => { const onClickAbout = jest.fn(); const {container} = renderWithIntl(getComponent({onClickAbout})); - const button = container.querySelector('button'); + const button = container.querySelector('button[aria-label="About"]'); expect(button).toBeTruthy(); }); @@ -57,7 +62,7 @@ describe('MenuBar Component', () => { test('clicking on About button calls the handler', () => { const onClickAbout = jest.fn(); const {container} = renderWithIntl(getComponent({onClickAbout})); - const button = container.querySelector('button'); + const button = container.querySelector('button[aria-label="About"]'); fireEvent.click(button); expect(onClickAbout).toHaveBeenCalledTimes(1); @@ -66,7 +71,7 @@ describe('MenuBar Component', () => { test('not clicking on About button does not call the handler', () => { const onClickAbout = jest.fn(); const {container} = renderWithIntl(getComponent({onClickAbout})); - const button = container.querySelector('button'); + const button = container.querySelector('button[aria-label="About"]'); expect(onClickAbout).toHaveBeenCalledTimes(0); });