diff --git a/src/components/menu-bar/icon--edit.svg b/src/components/menu-bar/icon--edit.svg
new file mode 100644
index 000000000..1ca2a883c
--- /dev/null
+++ b/src/components/menu-bar/icon--edit.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/components/menu-bar/icon--file.svg b/src/components/menu-bar/icon--file.svg
new file mode 100644
index 000000000..2042dae19
--- /dev/null
+++ b/src/components/menu-bar/icon--file.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/components/menu-bar/icon--settings.svg b/src/components/menu-bar/icon--settings.svg
new file mode 100644
index 000000000..aafdb3073
--- /dev/null
+++ b/src/components/menu-bar/icon--settings.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/components/menu-bar/language-menu.jsx b/src/components/menu-bar/language-menu.jsx
new file mode 100644
index 000000000..81402c317
--- /dev/null
+++ b/src/components/menu-bar/language-menu.jsx
@@ -0,0 +1,130 @@
+import classNames from 'classnames';
+import bindAll from 'lodash.bindall';
+import PropTypes from 'prop-types';
+import React from 'react';
+import {FormattedMessage} 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 styles from './settings-menu.css';
+
+import dropdownCaret from './dropdown-caret.svg';
+
+class LanguageMenu extends React.PureComponent {
+ constructor (props) {
+ super(props);
+ bindAll(this, [
+ 'setRef',
+ 'handleMouseOver'
+ ]);
+ }
+
+ 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'});
+ }
+ }
+
+ setRef (component) {
+ this.selectedRef = component;
+ }
+
+ handleMouseOver () {
+ // If we are using hover rather than clicks for submenus, scroll the selected option into view
+ if (!this.props.menuOpen && this.selectedRef) {
+ this.selectedRef.scrollIntoView({block: 'center'});
+ }
+ }
+
+ render () {
+ return (
+
+ );
+ }
+}
+
+LanguageMenu.propTypes = {
+ currentLocale: PropTypes.string,
+ isRtl: PropTypes.bool,
+ label: PropTypes.string,
+ menuOpen: PropTypes.bool,
+ onChangeLanguage: PropTypes.func,
+ onRequestCloseSettings: PropTypes.func,
+ onRequestOpen: PropTypes.func
+};
+
+const mapStateToProps = state => ({
+ currentLocale: state.locales.locale,
+ isRtl: state.locales.isRtl,
+ menuOpen: languageMenuOpen(state),
+ messagesByLocale: state.locales.messagesByLocale
+});
+
+const mapDispatchToProps = (dispatch, ownProps) => ({
+ onChangeLanguage: locale => {
+ dispatch(selectLocale(locale));
+ ownProps.onRequestCloseSettings();
+ },
+ onRequestOpen: () => dispatch(openLanguageMenu())
+});
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(LanguageMenu);
diff --git a/src/components/menu-bar/menu-bar.css b/src/components/menu-bar/menu-bar.css
index d35070da0..7f7e1bf1a 100644
--- a/src/components/menu-bar/menu-bar.css
+++ b/src/components/menu-bar/menu-bar.css
@@ -51,33 +51,6 @@
cursor: pointer;
}
-.language-icon {
- height: 1.5rem;
- vertical-align: middle;
- margin: 0 .125rem 0 0;
-}
-
-[dir="rtl"] .language-icon {
- margin: 0 0 0 .125rem;
-}
-
-.language-menu {
- box-sizing: border-box;
- display: inline-flex;
-}
-
-.language-label {
- max-width: 7rem;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- margin: 0 .5rem 0 .25rem;
-}
-
-[dir="rtl"] .language-label {
- margin: 0 .25rem 0 .5rem;
-}
-
.menu-bar-item {
display: flex;
padding: 0 0.25rem;
@@ -241,8 +214,16 @@
vertical-align: middle;
}
+.collapsible-label {
+ margin: 0 .5rem 0 .25rem;
+}
+
+[dir="rtl"] .collapsible-label {
+ margin: 0 0.25rem 0 0.5rem;
+}
+
@media only screen and (max-width: 1024px) {
- .language-label, .tutorials-label {
+ .tutorials-label, .collapsible-label {
display: none;
}
diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx
index 5ed80abee..ab3081775 100644
--- a/src/components/menu-bar/menu-bar.jsx
+++ b/src/components/menu-bar/menu-bar.jsx
@@ -8,7 +8,6 @@ import bowser from 'bowser';
import React from 'react';
import VM from 'scratch-vm';
-import locales from 'scratch-l10n';
import Box from '../box/box.jsx';
import Button from '../button/button.jsx';
@@ -16,7 +15,6 @@ import CommunityButton from './community-button.jsx';
import ShareButton from './share-button.jsx';
import {ComingSoonTooltip} from '../coming-soon/coming-soon.jsx';
import Divider from '../divider/divider.jsx';
-import LanguageSelector from '../../containers/language-selector.jsx';
import SaveStatus from './save-status.jsx';
import ProjectWatcher from '../../containers/project-watcher.jsx';
import MenuBarMenu from './menu-bar-menu.jsx';
@@ -29,7 +27,7 @@ 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 ThemeMenu from './theme-menu.jsx';
+import SettingsMenu from './settings-menu.jsx';
import {openTipsLibrary} from '../../reducers/modals';
import {setPlayer} from '../../reducers/mode';
@@ -63,20 +61,16 @@ import {
openEditMenu,
closeEditMenu,
editMenuOpen,
- openLanguageMenu,
- closeLanguageMenu,
- languageMenuOpen,
openLoginMenu,
closeLoginMenu,
loginMenuOpen,
- openThemeMenu,
- closeThemeMenu,
- themeMenuOpen,
openModeMenu,
closeModeMenu,
- modeMenuOpen
+ modeMenuOpen,
+ settingsMenuOpen,
+ openSettingsMenu,
+ closeSettingsMenu
} from '../../reducers/menus';
-import {setTheme} from '../../reducers/theme.js';
import collectMetadata from '../../lib/collect-metadata';
@@ -87,8 +81,9 @@ import mystuffIcon from './icon--mystuff.png';
import profileIcon from './icon--profile.png';
import remixIcon from './icon--remix.svg';
import dropdownCaret from './dropdown-caret.svg';
-import languageIcon from '../language-selector/language-icon.svg';
import aboutIcon from './icon--about.svg';
+import fileIcon from './icon--file.svg';
+import editIcon from './icon--edit.svg';
import scratchLogo from './scratch-logo.svg';
import ninetiesLogo from './nineties_logo.svg';
@@ -97,14 +92,8 @@ import prehistoricLogo from './prehistoric-logo.svg';
import oldtimeyLogo from './oldtimey-logo.svg';
import sharedMessages from '../../lib/shared-messages';
-import {persistTheme} from '../../lib/themes/themePersistance';
const ariaMessages = defineMessages({
- language: {
- id: 'gui.menuBar.LanguageSelector',
- defaultMessage: 'language selector',
- description: 'accessibility text for the language selection menu'
- },
tutorials: {
id: 'gui.menuBar.tutorialsLibrary',
defaultMessage: 'Tutorials',
@@ -183,7 +172,6 @@ class MenuBar extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
- 'handleChangeTheme',
'handleClickNew',
'handleClickRemix',
'handleClickSave',
@@ -192,7 +180,6 @@ class MenuBar extends React.Component {
'handleClickShare',
'handleSetMode',
'handleKeyPress',
- 'handleLanguageMouseUp',
'handleRestoreOption',
'getSaveToComputerHandler',
'restoreOptionMessage'
@@ -305,16 +292,6 @@ class MenuBar extends React.Component {
}
};
}
- handleLanguageMouseUp (e) {
- if (!this.props.languageMenuOpen) {
- this.props.onClickLanguage(e);
- }
- }
- handleChangeTheme (theme) {
- this.props.onChangeTheme(theme);
- this.props.onRequestCloseTheme();
- persistTheme(theme);
- }
restoreOptionMessage (deletedItem) {
switch (deletedItem) {
case 'Sprite':
@@ -459,29 +436,14 @@ class MenuBar extends React.Component {
onClick={this.props.onClickLogo}
/>
- {(this.props.canChangeLanguage) && (
-

-
{locales[this.props.currentLocale].name}
-

-
-
)}
- {(this.props.canChangeTheme) && ()}
-
{(this.props.canManageFiles) && (
-
+

+
+
+
+
-
+

+
-
+
+
{
isRtl: state.locales.isRtl,
isUpdating: getIsUpdating(loadingState),
isShowingProject: getIsShowingProject(loadingState),
- languageMenuOpen: languageMenuOpen(state),
locale: state.locales.locale,
loginMenuOpen: loginMenuOpen(state),
modeMenuOpen: modeMenuOpen(state),
projectTitle: state.scratchGui.projectTitle,
sessionExists: state.session && typeof state.session.session !== 'undefined',
- theme: state.scratchGui.theme.theme,
- themeMenuOpen: themeMenuOpen(state),
+ settingsMenuOpen: settingsMenuOpen(state),
username: user ? user.username : null,
userOwnsProject: ownProps.authorUsername && user &&
(ownProps.authorUsername === user.username),
@@ -1008,22 +969,19 @@ const mapDispatchToProps = dispatch => ({
onRequestCloseFile: () => dispatch(closeFileMenu()),
onClickEdit: () => dispatch(openEditMenu()),
onRequestCloseEdit: () => dispatch(closeEditMenu()),
- onClickLanguage: () => dispatch(openLanguageMenu()),
- onRequestCloseLanguage: () => dispatch(closeLanguageMenu()),
onClickLogin: () => dispatch(openLoginMenu()),
onRequestCloseLogin: () => dispatch(closeLoginMenu()),
onClickMode: () => dispatch(openModeMenu()),
onRequestCloseMode: () => dispatch(closeModeMenu()),
onRequestOpenAbout: () => dispatch(openAboutMenu()),
onRequestCloseAbout: () => dispatch(closeAboutMenu()),
- onRequestOpenTheme: () => dispatch(openThemeMenu()),
- onRequestCloseTheme: () => dispatch(closeThemeMenu()),
+ onClickSettings: () => dispatch(openSettingsMenu()),
+ onRequestCloseSettings: () => dispatch(closeSettingsMenu()),
onClickNew: needSave => dispatch(requestNewProject(needSave)),
onClickRemix: () => dispatch(remixProject()),
onClickSave: () => dispatch(manualUpdateProject()),
onClickSaveAsCopy: () => dispatch(saveProjectAsCopy()),
onSeeCommunity: () => dispatch(setPlayer(true)),
- onChangeTheme: theme => dispatch(setTheme(theme)),
onSetTimeTravelMode: mode => dispatch(setTimeTravel(mode))
});
diff --git a/src/components/menu-bar/settings-menu.css b/src/components/menu-bar/settings-menu.css
new file mode 100644
index 000000000..4171aa150
--- /dev/null
+++ b/src/components/menu-bar/settings-menu.css
@@ -0,0 +1,67 @@
+.icon {
+ width: 1.5rem;
+}
+
+.theme-label {
+ flex: 1;
+}
+
+.option {
+ display: flex;
+ align-items: center;
+ gap: .5rem;
+}
+
+.check {
+ margin: 0 .25rem 0 0;
+ visibility: hidden;
+}
+
+[dir="rtl"] .check {
+ margin: 0 0 0 .25rem;
+}
+
+.selected {
+ visibility: visible;
+}
+
+.submenu-label {
+ flex: 1;
+}
+
+.dropdown-label {
+ margin: 0 0.5rem 0 0.25rem;
+}
+
+[dir="rtl"] .dropdown-label {
+ margin: 0 0.25rem 0 0.5rem;
+}
+
+.expand-caret {
+ transform: rotate(-90deg);
+}
+
+[dir="rtl"] .expand-caret {
+ transform: rotate(90deg);
+}
+
+/* Extra padding to deal with scrollbar overlapping options in Firefox */
+
+[dir="ltr"] .language-menu-item {
+ padding-right: 1rem;
+}
+
+[dir="rtl"] .language-menu-item {
+ padding-left: 1rem;
+}
+
+.language-submenu > ul {
+ max-height: calc(100vh - 5rem); /* Fallback if dvh not supported */
+ max-height: calc(100dvh - 5rem);
+}
+
+@media only screen and (max-width: 1024px) {
+ .dropdown-label {
+ display: none;
+ }
+}
diff --git a/src/components/menu-bar/settings-menu.jsx b/src/components/menu-bar/settings-menu.jsx
new file mode 100644
index 000000000..2b0ac7bae
--- /dev/null
+++ b/src/components/menu-bar/settings-menu.jsx
@@ -0,0 +1,65 @@
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import {FormattedMessage} from 'react-intl';
+
+import LanguageMenu from './language-menu.jsx';
+import MenuBarMenu from './menu-bar-menu.jsx';
+import ThemeMenu from './theme-menu.jsx';
+import {MenuSection} from '../menu/menu.jsx';
+
+import menuBarStyles from './menu-bar.css';
+import styles from './settings-menu.css';
+
+import dropdownCaret from './dropdown-caret.svg';
+import settingsIcon from './icon--settings.svg';
+
+const SettingsMenu = ({
+ canChangeLanguage,
+ canChangeTheme,
+ isRtl,
+ onRequestClose,
+ onRequestOpen,
+ settingsMenuOpen
+}) => (
+
+

+
+
+
+

+
+
+ {canChangeLanguage && }
+ {canChangeTheme && }
+
+
+
+);
+
+SettingsMenu.propTypes = {
+ canChangeLanguage: PropTypes.bool,
+ canChangeTheme: PropTypes.bool,
+ isRtl: PropTypes.bool,
+ onRequestClose: PropTypes.func,
+ onRequestOpen: PropTypes.func,
+ settingsMenuOpen: PropTypes.bool
+};
+
+export default SettingsMenu;
diff --git a/src/components/menu-bar/theme-menu.css b/src/components/menu-bar/theme-menu.css
deleted file mode 100644
index 7c79945c2..000000000
--- a/src/components/menu-bar/theme-menu.css
+++ /dev/null
@@ -1,52 +0,0 @@
-@import "../../css/colors.css";
-
-.theme-label {
- max-width: 7rem;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- margin: 0 .5rem 0 .25rem;
-}
-
-[dir="rtl"] .theme-label {
- margin: 0 .25rem 0 .5rem;
-}
-
-.theme-icon {
- height: 1.5rem;
- vertical-align: middle;
- margin: 0 .25rem 0 0;
-}
-
-[dir="rtl"] .theme-icon {
- margin: 0 0 0 .25rem;
-}
-
-.theme-option {
- display: flex;
- align-items: center;
- gap: .5rem;
-}
-
-.theme-active {
- background-color: $ui-black-transparent;
-}
-
-.check {
- margin: 0 .25rem 0 0;
- visibility: hidden;
-}
-
-[dir="rtl"] .check {
- margin: 0 0 0 .25rem;
-}
-
-.selected {
- visibility: visible;
-}
-
-@media only screen and (max-width: 1024px) {
- .theme-label {
- display: none;
- }
-}
diff --git a/src/components/menu-bar/theme-menu.jsx b/src/components/menu-bar/theme-menu.jsx
index afd58aa53..e9ce24f1d 100644
--- a/src/components/menu-bar/theme-menu.jsx
+++ b/src/components/menu-bar/theme-menu.jsx
@@ -1,105 +1,118 @@
+import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
-import {defineMessages, FormattedMessage, injectIntl, intlShape} from 'react-intl';
-import classNames from 'classnames';
+import {FormattedMessage} from 'react-intl';
+import {connect} from 'react-redux';
-import MenuBarMenu from './menu-bar-menu.jsx';
+import check from './check.svg';
+import {MenuItem, Submenu} from '../menu/menu.jsx';
import {DEFAULT_THEME, HIGH_CONTRAST_THEME, themeMap} from '../../lib/themes';
-import {MenuItem, MenuSection} from '../menu/menu.jsx';
+import {persistTheme} from '../../lib/themes/themePersistance';
+import {openThemeMenu, themeMenuOpen} from '../../reducers/menus.js';
+import {setTheme} from '../../reducers/theme.js';
+
+import styles from './settings-menu.css';
import dropdownCaret from './dropdown-caret.svg';
-import check from './check.svg';
-
-import styles from './theme-menu.css';
-import menuBarStyles from './menu-bar.css';
-
-const messages = defineMessages({
- colorTheme: {
- id: 'gui.theme.colorTheme',
- defaultMessage: 'Color theme',
- description: 'label for selecting the site\'s color theme'
- }
-});
const ThemeMenuItem = props => {
const themeInfo = themeMap[props.theme];
return (
-
-
+
+
);
};
ThemeMenuItem.propTypes = {
- isRtl: PropTypes.bool,
isSelected: PropTypes.bool,
onClick: PropTypes.func,
theme: PropTypes.string
};
-const ThemeMenu = props => {
+const ThemeMenu = ({
+ isRtl,
+ menuOpen,
+ onChangeTheme,
+ onRequestOpen,
+ theme
+}) => {
const enabledThemes = [DEFAULT_THEME, HIGH_CONTRAST_THEME];
- const themeInfo = themeMap[props.theme];
+ const themeInfo = themeMap[theme];
- return (
-

-
-
-
-

-
-
+ return (
+
+
+

+
+
+
+

+
+
{enabledThemes.map(enabledTheme => (
props.onChange(enabledTheme)}
+ onClick={() => onChangeTheme(enabledTheme)}
theme={enabledTheme}
/>)
)}
-
-
-
);
+
+
+ );
};
ThemeMenu.propTypes = {
- className: PropTypes.string,
- intl: intlShape.isRequired,
isRtl: PropTypes.bool,
- onChange: PropTypes.func,
- onRequestClose: PropTypes.func,
+ menuOpen: PropTypes.bool,
+ onChangeTheme: PropTypes.func,
+ // eslint-disable-next-line react/no-unused-prop-types
+ onRequestCloseSettings: PropTypes.func,
onRequestOpen: PropTypes.func,
- theme: PropTypes.string,
- themeMenuOpen: PropTypes.bool
+ theme: PropTypes.string
};
-export default injectIntl(ThemeMenu);
+const mapStateToProps = state => ({
+ isRtl: state.locales.isRtl,
+ menuOpen: themeMenuOpen(state),
+ theme: state.scratchGui.theme.theme
+});
+
+const mapDispatchToProps = (dispatch, ownProps) => ({
+ onChangeTheme: theme => {
+ dispatch(setTheme(theme));
+ ownProps.onRequestCloseSettings();
+ persistTheme(theme);
+ },
+ onRequestOpen: () => dispatch(openThemeMenu())
+});
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(ThemeMenu);
diff --git a/src/components/menu/menu.css b/src/components/menu/menu.css
index e5d0a66ef..64cfccedd 100644
--- a/src/components/menu/menu.css
+++ b/src/components/menu/menu.css
@@ -30,10 +30,12 @@
font-size: .75rem;
margin: 0;
font-weight: bold;
+ position: relative;
}
.menu-item.active,
-.menu-item:hover {
+.menu-item:hover,
+.menu-item.expanded {
background-color: $ui-black-transparent;
}
@@ -44,3 +46,54 @@
.menu-section {
border-top: 1px solid $ui-black-transparent;
}
+
+.submenu {
+ display: none;
+ position: absolute;
+ top: -1px;
+ min-width: 186px;
+ max-width: 260px;
+ height: max-content;
+ overflow: hidden;
+ border-radius: 8px;
+}
+
+.submenu.right {
+ left: 100%;
+}
+
+.submenu.left {
+ right: 100%;
+}
+
+.submenu > .menu {
+ position: relative;
+ overflow-y: auto;
+ overflow-x: hidden;
+ scrollbar-color: $ui-black-transparent $looks-secondary;
+ border-radius: 8px;
+}
+
+.menu-item.expanded > .submenu {
+ display: block;
+}
+
+:not(:has(> .menu-item.expanded)) > .menu-item:hover > .submenu {
+ display: block;
+}
+
+/* Fallback styling for webkit browsers that don't support scrollbar-color */
+
+.submenu > .menu::-webkit-scrollbar {
+ width: 12px;
+}
+
+.submenu > .menu::-webkit-scrollbar-track {
+ background: $looks-secondary;
+}
+
+.submenu > .menu::-webkit-scrollbar-thumb {
+ background-color: $ui-black-transparent;
+ border-radius: 12px;
+ border: 3px solid $looks-secondary;
+}
diff --git a/src/components/menu/menu.jsx b/src/components/menu/menu.jsx
index 9f14ba52b..5be46a32e 100644
--- a/src/components/menu/menu.jsx
+++ b/src/components/menu/menu.jsx
@@ -33,16 +33,44 @@ MenuComponent.propTypes = {
};
+const Submenu = ({children, className, place, ...props}) => (
+
+
+ {children}
+
+
+);
+
+Submenu.propTypes = {
+ children: PropTypes.node,
+ className: PropTypes.string,
+ place: PropTypes.oneOf(['left', 'right'])
+};
+
const MenuItem = ({
children,
className,
+ expanded = false,
onClick
}) => (
@@ -53,6 +81,7 @@ const MenuItem = ({
MenuItem.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
+ expanded: PropTypes.bool,
onClick: PropTypes.func
};
@@ -80,5 +109,6 @@ MenuSection.propTypes = {
export {
MenuComponent as default,
MenuItem,
- MenuSection
+ MenuSection,
+ Submenu
};
diff --git a/src/reducers/menus.js b/src/reducers/menus.js
index c265e1e5c..f8511f484 100644
--- a/src/reducers/menus.js
+++ b/src/reducers/menus.js
@@ -3,36 +3,99 @@ const CLOSE_MENU = 'scratch-gui/menus/CLOSE_MENU';
const MENU_ABOUT = 'aboutMenu';
const MENU_ACCOUNT = 'accountMenu';
-const MENU_FILE = 'fileMenu';
const MENU_EDIT = 'editMenu';
+const MENU_FILE = 'fileMenu';
const MENU_LANGUAGE = 'languageMenu';
const MENU_LOGIN = 'loginMenu';
-const MENU_THEME = 'themeMenu';
const MENU_MODE = 'modeMenu';
+const MENU_SETTINGS = 'settingsMenu';
+const MENU_THEME = 'themeMenu';
+class Menu {
+ constructor (id) {
+ this.id = id;
+ this.children = [];
+ this.parent = null;
+ }
+
+ addChild (menu) {
+ this.children.push(menu);
+ menu.parent = this;
+ return this;
+ }
+
+ descendants () {
+ return this.children.flatMap(child => [child, ...child.descendants()]);
+ }
+
+ siblings () {
+ if (!this.parent) return [];
+
+ return this.parent.children.filter(child => child.id !== this.id);
+ }
+
+ findById (id) {
+ if (this.id === id) return this;
+
+ for (const child of this.children) {
+ const found = child.findById(id);
+ if (found) return found;
+ }
+
+ return null;
+ }
+}
+
+// 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_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_ABOUT));
const initialState = {
[MENU_ABOUT]: false,
[MENU_ACCOUNT]: false,
- [MENU_FILE]: false,
[MENU_EDIT]: false,
+ [MENU_FILE]: false,
[MENU_LANGUAGE]: false,
[MENU_LOGIN]: false,
- [MENU_THEME]: false,
- [MENU_MODE]: false
+ [MENU_MODE]: false,
+ [MENU_SETTINGS]: false,
+ [MENU_THEME]: false
};
const reducer = function (state, action) {
if (typeof state === 'undefined') state = initialState;
switch (action.type) {
- case OPEN_MENU:
- return Object.assign({}, state, {
- [action.menu]: true
- });
- case CLOSE_MENU:
- return Object.assign({}, state, {
- [action.menu]: false
- });
+ case OPEN_MENU: {
+ const menu = rootMenu.findById(action.menu);
+ // Close siblings when opening a menu
+ const toClose = menu.siblings().flatMap(sibling => [sibling, ...sibling.descendants()]);
+
+ return {
+ ...state,
+ [action.menu]: true,
+ ...Object.fromEntries(toClose.map(({id}) => [id, false]))
+ };
+ }
+ case CLOSE_MENU: {
+ const menu = rootMenu.findById(action.menu);
+ // Close this menu and any submenus
+ const toClose = [menu, ...menu.descendants()];
+
+ return {
+ ...state,
+ ...Object.fromEntries(toClose.map(({id}) => [id, false]))
+ };
+ }
default:
return state;
}
@@ -45,31 +108,43 @@ const closeMenu = menu => ({
type: CLOSE_MENU,
menu: menu
});
+
const openAboutMenu = () => openMenu(MENU_ABOUT);
const closeAboutMenu = () => closeMenu(MENU_ABOUT);
const aboutMenuOpen = state => state.scratchGui.menus[MENU_ABOUT];
+
const openAccountMenu = () => openMenu(MENU_ACCOUNT);
const closeAccountMenu = () => closeMenu(MENU_ACCOUNT);
const accountMenuOpen = state => state.scratchGui.menus[MENU_ACCOUNT];
-const openFileMenu = () => openMenu(MENU_FILE);
-const closeFileMenu = () => closeMenu(MENU_FILE);
-const fileMenuOpen = state => state.scratchGui.menus[MENU_FILE];
+
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 openThemeMenu = () => openMenu(MENU_THEME);
-const closeThemeMenu = () => closeMenu(MENU_THEME);
-const themeMenuOpen = state => state.scratchGui.menus[MENU_THEME];
+
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 openThemeMenu = () => openMenu(MENU_THEME);
+const closeThemeMenu = () => closeMenu(MENU_THEME);
+const themeMenuOpen = state => state.scratchGui.menus[MENU_THEME];
+
export {
reducer as default,
initialState as menuInitialState,
@@ -79,22 +154,25 @@ export {
openAccountMenu,
closeAccountMenu,
accountMenuOpen,
- openFileMenu,
- closeFileMenu,
- fileMenuOpen,
openEditMenu,
closeEditMenu,
editMenuOpen,
+ openFileMenu,
+ closeFileMenu,
+ fileMenuOpen,
openLanguageMenu,
closeLanguageMenu,
languageMenuOpen,
openLoginMenu,
closeLoginMenu,
loginMenuOpen,
- openThemeMenu,
- closeThemeMenu,
- themeMenuOpen,
openModeMenu,
closeModeMenu,
- modeMenuOpen
+ modeMenuOpen,
+ openSettingsMenu,
+ closeSettingsMenu,
+ settingsMenuOpen,
+ openThemeMenu,
+ closeThemeMenu,
+ themeMenuOpen
};
diff --git a/test/integration/blocks.test.js b/test/integration/blocks.test.js
index 1a709d34a..252110078 100644
--- a/test/integration/blocks.test.js
+++ b/test/integration/blocks.test.js
@@ -21,6 +21,9 @@ const uri = path.resolve(__dirname, '../../build/index.html');
let driver;
+const SETTINGS_MENU_XPATH = '//div[contains(@class, "menu-bar_menu-bar-item")]' +
+ '[*[contains(@class, "settings-menu_dropdown-label")]//*[text()="Settings"]]';
+
describe('Working with the blocks', () => {
beforeAll(() => {
driver = getDriver();
@@ -309,7 +312,8 @@ describe('Working with the blocks', () => {
await findByText('1', scope.reportedValue);
// change language
- await clickXpath('//*[@aria-label="language selector"]');
+ await clickXpath(SETTINGS_MENU_XPATH);
+ await clickText('Language', scope.menuBar);
await clickText('Deutsch');
await clickText('Skripte');
diff --git a/test/integration/localization.test.js b/test/integration/localization.test.js
index 12554507f..e21e05bd6 100644
--- a/test/integration/localization.test.js
+++ b/test/integration/localization.test.js
@@ -17,6 +17,11 @@ const uri = path.resolve(__dirname, '../../build/index.html');
let driver;
+const FILE_MENU_XPATH = '//div[contains(@class, "menu-bar_menu-bar-item")]' +
+ '[*[contains(@class, "menu-bar_collapsible-label")]//*[text()="File"]]';
+const SETTINGS_MENU_XPATH = '//div[contains(@class, "menu-bar_menu-bar-item")]' +
+ '[*[contains(@class, "settings-menu_dropdown-label")]//*[text()="Settings"]]';
+
describe('Localization', () => {
beforeAll(() => {
driver = getDriver();
@@ -33,7 +38,8 @@ describe('Localization', () => {
await clickXpath('//button[@aria-label="Choose a Sprite"]');
await clickText('Apple', scope.modal); // Closes modal
- await clickXpath('//*[@aria-label="language selector"]');
+ await clickXpath(SETTINGS_MENU_XPATH);
+ await clickText('Language', scope.menuBar);
await clickText('Deutsch');
await new Promise(resolve => setTimeout(resolve, 1000)); // wait for blocks refresh
@@ -79,7 +85,7 @@ describe('Localization', () => {
// Regression test for ENA-142, monitor can lag behind language selection
test('Monitor labels update on locale change', async () => {
await loadUri(uri);
- await clickText('File');
+ await clickXpath(FILE_MENU_XPATH);
await clickText('Load from your computer');
const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/monitor-variable.sb3'));
@@ -89,7 +95,8 @@ describe('Localization', () => {
await findByText('language', scope.monitors);
// Change locale to ja
- await clickXpath('//*[@aria-label="language selector"]');
+ await clickXpath(SETTINGS_MENU_XPATH);
+ await clickText('Language', scope.menuBar);
await clickText('日本語');
// Monitor labels updated
@@ -97,6 +104,8 @@ describe('Localization', () => {
await findByText('言語', scope.monitors);
// Change locale to ja-hira
+ await clickXpath(SETTINGS_MENU_XPATH);
+ await clickText('Language', scope.menuBar);
await clickText('にほんご');
// Monitor labels updated
diff --git a/test/integration/menu-bar.test.js b/test/integration/menu-bar.test.js
index 4c6b1fd4e..45d6ec4e7 100644
--- a/test/integration/menu-bar.test.js
+++ b/test/integration/menu-bar.test.js
@@ -16,6 +16,11 @@ const uri = path.resolve(__dirname, '../../build/index.html');
let driver;
+const FILE_MENU_XPATH = '//div[contains(@class, "menu-bar_menu-bar-item")]' +
+ '[*[contains(@class, "menu-bar_collapsible-label")]//*[text()="File"]]';
+const SETTINGS_MENU_XPATH = '//div[contains(@class, "menu-bar_menu-bar-item")]' +
+ '[*[contains(@class, "settings-menu_dropdown-label")]//*[text()="Settings"]]';
+
describe('Menu bar settings', () => {
beforeAll(() => {
driver = getDriver();
@@ -27,28 +32,19 @@ describe('Menu bar settings', () => {
test('File->New should be enabled', async () => {
await loadUri(uri);
- await clickXpath(
- '//div[contains(@class, "menu-bar_menu-bar-item") and ' +
- 'contains(@class, "menu-bar_hoverable")][span[text()="File"]]'
- );
+ await clickXpath(FILE_MENU_XPATH);
await findByXpath('//*[li[span[text()="New"]] and not(@data-tip="tooltip")]');
});
test('File->Load should be enabled', async () => {
await loadUri(uri);
- await clickXpath(
- '//div[contains(@class, "menu-bar_menu-bar-item") and ' +
- 'contains(@class, "menu-bar_hoverable")][span[text()="File"]]'
- );
+ await clickXpath(FILE_MENU_XPATH);
await findByXpath('//*[li[text()="Load from your computer"] and not(@data-tip="tooltip")]');
});
test('File->Save should be enabled', async () => {
await loadUri(uri);
- await clickXpath(
- '//div[contains(@class, "menu-bar_menu-bar-item") and ' +
- 'contains(@class, "menu-bar_hoverable")][span[text()="File"]]'
- );
+ await clickXpath(FILE_MENU_XPATH);
await findByXpath('//*[li[span[text()="Save to your computer"]] and not(@data-tip="tooltip")]');
});
@@ -74,7 +70,7 @@ describe('Menu bar settings', () => {
test('User is not warned before uploading project file over a fresh project', async () => {
await loadUri(uri);
- await clickText('File');
+ await clickXpath(FILE_MENU_XPATH);
await clickText('Load from your computer');
const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/project1.sb3'));
@@ -89,7 +85,7 @@ describe('Menu bar settings', () => {
await rightClickText('Sprite1', scope.spriteTile);
await clickText('delete', scope.spriteTile);
- await clickText('File');
+ await clickXpath(FILE_MENU_XPATH);
await clickText('Load from your computer');
const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/project1.sb3'));
@@ -100,15 +96,17 @@ describe('Menu bar settings', () => {
test('Theme picker shows themes', async () => {
await loadUri(uri);
- await clickXpath('//img[@alt="Color theme"]');
+ await clickXpath(SETTINGS_MENU_XPATH);
+ await clickText('Color Mode', scope.menuBar);
- await findByText('Original', scope.menuBar);
- await findByText('High Contrast', scope.menuBar);
+ expect(await (await findByText('Original', scope.menuBar)).isDisplayed()).toBe(true);
+ expect(await (await findByText('High Contrast', scope.menuBar)).isDisplayed()).toBe(true);
});
test('Theme picker switches to high contrast', async () => {
await loadUri(uri);
- await clickXpath('//img[@alt="Color theme"]');
+ await clickXpath(SETTINGS_MENU_XPATH);
+ await clickText('Color Mode', scope.menuBar);
await clickText('High Contrast', scope.menuBar);
// There is a tiny delay for the color theme to be applied to the categories.
@@ -124,4 +122,51 @@ describe('Menu bar settings', () => {
return /128,\s?181,\s?255/.test(color) || color.includes('80B5FF');
}, 5000, 'Motion category color does not match high contrast theme');
});
+
+ test('Settings menu switches between submenus', async () => {
+ await loadUri(uri);
+ await clickXpath(SETTINGS_MENU_XPATH);
+
+ // Language and theme options not visible yet
+ expect(await (await findByText('High Contrast', scope.menuBar)).isDisplayed()).toBe(false);
+ expect(await (await findByText('Esperanto', scope.menuBar)).isDisplayed()).toBe(false);
+
+ await clickText('Color Mode', scope.menuBar);
+
+ // Only theme options visible
+ expect(await (await findByText('High Contrast', scope.menuBar)).isDisplayed()).toBe(true);
+ expect(await (await findByText('Esperanto', scope.menuBar)).isDisplayed()).toBe(false);
+
+ await clickText('Language', scope.menuBar);
+
+ // Only language options visible
+ expect(await (await findByText('High Contrast', scope.menuBar)).isDisplayed()).toBe(false);
+ expect(await (await findByText('Esperanto', scope.menuBar)).isDisplayed()).toBe(true);
+ });
+
+ test('Menu labels hidden when width is equal to 1024', async () => {
+ await loadUri(uri);
+ await driver.manage()
+ .window()
+ .setSize(1024, 768);
+
+ const collapsibleMenus = ['Settings', 'File', 'Edit', 'Tutorials'];
+ for (const menu of collapsibleMenus) {
+ const settingsMenu = await findByText(menu, scope.menuBar);
+ expect(await settingsMenu.isDisplayed()).toBe(false);
+ }
+ });
+
+ test('Menu labels shown when width is greater than 1024', async () => {
+ await loadUri(uri);
+ await driver.manage()
+ .window()
+ .setSize(1200, 768);
+
+ const collapsibleMenus = ['Settings', 'File', 'Edit', 'Tutorials'];
+ for (const menu of collapsibleMenus) {
+ const settingsMenu = await findByText(menu, scope.menuBar);
+ expect(await settingsMenu.isDisplayed()).toBe(true);
+ }
+ });
});
diff --git a/test/integration/project-loading.test.js b/test/integration/project-loading.test.js
index b949799dd..1b1bcf20d 100644
--- a/test/integration/project-loading.test.js
+++ b/test/integration/project-loading.test.js
@@ -16,6 +16,9 @@ const uri = path.resolve(__dirname, '../../build/index.html');
let driver;
+const FILE_MENU_XPATH = '//div[contains(@class, "menu-bar_menu-bar-item")]' +
+ '[*[contains(@class, "menu-bar_collapsible-label")]//*[text()="File"]]';
+
describe('Loading scratch gui', () => {
beforeAll(() => {
driver = getDriver();
@@ -82,10 +85,7 @@ describe('Loading scratch gui', () => {
await loadUri(uri);
await findByXpath('//*[span[text()="Costumes"]]');
await clickText('Costumes');
- await clickXpath(
- '//div[contains(@class, "menu-bar_menu-bar-item") and ' +
- 'contains(@class, "menu-bar_hoverable")][span[text()="File"]]'
- );
+ await clickXpath(FILE_MENU_XPATH);
await clickXpath('//li[span[text()="New"]]');
await findByXpath('//div[@class="scratchCategoryMenu"]');
await clickText('Operators', scope.blocksTab);
@@ -93,10 +93,7 @@ describe('Loading scratch gui', () => {
test('Not logged in->made no changes to project->create new project should not show alert', async () => {
await loadUri(uri);
- await clickXpath(
- '//div[contains(@class, "menu-bar_menu-bar-item") and ' +
- 'contains(@class, "menu-bar_hoverable")][span[text()="File"]]'
- );
+ await clickXpath(FILE_MENU_XPATH);
await clickXpath('//li[span[text()="New"]]');
await findByXpath('//*[div[@class="scratchCategoryMenu"]]');
await clickText('Operators', scope.blocksTab);
@@ -108,10 +105,7 @@ describe('Loading scratch gui', () => {
await clickXpath('//button[@aria-label="Choose a Sound"]');
await clickText('A Bass', scope.modal); // Should close the modal
await findByText('1.28'); // length of A Bass sound
- await clickXpath(
- '//div[contains(@class, "menu-bar_menu-bar-item") and ' +
- 'contains(@class, "menu-bar_hoverable")][span[text()="File"]]'
- );
+ await clickXpath(FILE_MENU_XPATH);
await clickXpath('//li[span[text()="New"]]');
driver.switchTo()
.alert()
diff --git a/test/integration/project-state.test.js b/test/integration/project-state.test.js
index e155893b3..cb1c245a4 100644
--- a/test/integration/project-state.test.js
+++ b/test/integration/project-state.test.js
@@ -14,6 +14,9 @@ const uri = path.resolve(__dirname, '../../build/index.html');
let driver;
+const FILE_MENU_XPATH = '//div[contains(@class, "menu-bar_menu-bar-item")]' +
+ '[*[contains(@class, "menu-bar_collapsible-label")]//*[text()="File"]]';
+
describe('Project state', () => {
beforeAll(() => {
driver = getDriver();
@@ -34,10 +37,7 @@ describe('Project state', () => {
await clickText('Costumes'); // just to blur the input
// verify that project title has changed
await clickXpath('//input[@value="Changed title of project"]');
- await clickXpath(
- '//div[contains(@class, "menu-bar_menu-bar-item") and ' +
- 'contains(@class, "menu-bar_hoverable")][span[text()="File"]]'
- );
+ await clickXpath(FILE_MENU_XPATH);
await clickXpath('//li[span[text()="New"]]');
// project title should be default again
await clickXpath(`//input[@value="${defaultProjectTitle}"]`);
diff --git a/test/integration/sb-file-uploader-hoc.test.js b/test/integration/sb-file-uploader-hoc.test.js
index 3b4602983..1b38f4978 100644
--- a/test/integration/sb-file-uploader-hoc.test.js
+++ b/test/integration/sb-file-uploader-hoc.test.js
@@ -14,6 +14,9 @@ const uri = path.resolve(__dirname, '../../build/index.html');
let driver;
+const FILE_MENU_XPATH = '//div[contains(@class, "menu-bar_menu-bar-item")]' +
+ '[*[contains(@class, "menu-bar_collapsible-label")]//*[text()="File"]]';
+
describe('Loading scratch gui', () => {
beforeAll(() => {
driver = getDriver();
@@ -25,7 +28,7 @@ describe('Loading scratch gui', () => {
test('Loading project file from computer succeeds, without opening failure alert', async () => {
await loadUri(uri);
- await clickText('File');
+ await clickXpath(FILE_MENU_XPATH);
await clickText('Load from your computer');
const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/project1.sb3'));
@@ -35,7 +38,7 @@ describe('Loading scratch gui', () => {
test('Loading project file from computer gives project the filename from file', async () => {
await loadUri(uri);
- await clickText('File');
+ await clickXpath(FILE_MENU_XPATH);
await clickText('Load from your computer');
const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/project1.sb3'));
@@ -45,7 +48,7 @@ describe('Loading scratch gui', () => {
test('Load sb3 project with a missing svg costume', async () => {
await loadUri(uri);
- await clickText('File');
+ await clickXpath(FILE_MENU_XPATH);
await clickText('Load from your computer');
const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/missing-sprite-svg.sb3'));
@@ -56,7 +59,7 @@ describe('Loading scratch gui', () => {
test('Load sb3 project with an invalid svg costume', async () => {
await loadUri(uri);
- await clickText('File');
+ await clickXpath(FILE_MENU_XPATH);
await clickText('Load from your computer');
const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/corrupt-svg.sb3'));
@@ -67,7 +70,7 @@ describe('Loading scratch gui', () => {
test('Load sb2 project with a missing svg costume', async () => {
await loadUri(uri);
- await clickText('File');
+ await clickXpath(FILE_MENU_XPATH);
await clickText('Load from your computer');
const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/missing-svg.sb2'));
@@ -78,7 +81,7 @@ describe('Loading scratch gui', () => {
test('Load sb2 project with an invalid svg costume', async () => {
await loadUri(uri);
- await clickText('File');
+ await clickXpath(FILE_MENU_XPATH);
await clickText('Load from your computer');
const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/corrupt-svg.sb2'));
@@ -89,7 +92,7 @@ describe('Loading scratch gui', () => {
test('Load sb3 project with a missing bmp costume', async () => {
await loadUri(uri);
- await clickText('File');
+ await clickXpath(FILE_MENU_XPATH);
await clickText('Load from your computer');
const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/missing-bmp.sb3'));
@@ -100,7 +103,7 @@ describe('Loading scratch gui', () => {
test('Load sb3 project with an invalid bmp costume', async () => {
await loadUri(uri);
- await clickText('File');
+ await clickXpath(FILE_MENU_XPATH);
await clickText('Load from your computer');
const input = await findByXpath('//input[@accept=".sb,.sb2,.sb3"]');
await input.sendKeys(path.resolve(__dirname, '../fixtures/corrupt-bmp.sb3'));