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 ( + +
+ + + + + +
+ + { + Object.keys(locales) + .map(locale => ( + this.props.onChangeLanguage(locale)} + > + + {locales[locale].name} + + )) + } + +
+ ); + } +} + +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 (
- {props.intl.formatMessage(messages.colorTheme)} - - - - - - + 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'));