Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React from 'react';
import PropTypes from 'prop-types';
import bindAll from 'lodash.bindall';

export const MenuRefContext = React.createContext(null);

export class MenuRefProvider extends React.Component {
constructor (props) {
super(props);

this.state = {
openRefs: []
};

bindAll(this, [
'addInner',
'isTopMenu',
'isOpenMenu',
'removeAll',
'removeByRef',
'removeInner'
]);
}

isTopMenu (ref) {
const {openRefs} = this.state;
return openRefs.length > 0 && openRefs[openRefs.length - 1] === ref;
}

isOpenMenu (ref) {
return this.state.openRefs.includes(ref);
}

addInner (ref) {
this.setState(prev => ({
openRefs: [...prev.openRefs, ref]
}));
}

removeByRef (ref) {
this.setState(prev => {
const refs = prev.openRefs;
const index = refs.indexOf(ref);

if (index === -1) return {openRefs: refs};

return {
openRefs: refs.slice(0, index)
};
});
}

removeInner () {
this.setState(prev => ({
openRefs: prev.openRefs.slice(0, prev.openRefs.length - 1)
}));
}

removeAll () {
this.setState({openRefs: []});
}

// printChain () {
// console.log(this.state.openRefs);
// }
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove commented-out debugging code. The printChain method and its reference in the context value should be deleted before merging.

Copilot uses AI. Check for mistakes.

render () {
const value = {
openRefs: this.state.openRefs,
isTopMenu: this.isTopMenu,
isOpenMenu: this.isOpenMenu,
addInner: this.addInner,
removeInner: this.removeInner,
removeAll: this.removeAll,
removeByRef: this.removeByRef
// printChain: this.printChain
};

return (
<MenuRefContext.Provider value={value}>
{this.props.children}
</MenuRefContext.Provider>
);
}
}

MenuRefProvider.propTypes = {
children: PropTypes.node
};
77 changes: 41 additions & 36 deletions packages/scratch-gui/src/components/gui/gui.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import soundsIcon from './icon--sounds.svg';
import DebugModal from '../debug-modal/debug-modal.jsx';
import {setPlatform} from '../../reducers/platform.js';
import {PLATFORM} from '../../lib/platform.js';
import {MenuRefProvider} from '../context-menu/menu-path-context.jsx';

// Cache this value to only retrieve it once the first time.
// Assume that it doesn't change for a session.
Expand Down Expand Up @@ -252,42 +253,46 @@ const GUIComponent = props => {
onRequestClose={onRequestCloseBackdropLibrary}
/>
) : null}
{!menuBarHidden && <MenuBar
accountNavOpen={accountNavOpen}
authorId={authorId}
authorThumbnailUrl={authorThumbnailUrl}
authorUsername={authorUsername}
canChangeLanguage={canChangeLanguage}
canChangeTheme={canChangeTheme}
canCreateCopy={canCreateCopy}
canCreateNew={canCreateNew}
canEditTitle={canEditTitle}
canManageFiles={canManageFiles}
canRemix={canRemix}
canSave={canSave}
canShare={canShare}
className={styles.menuBarPosition}
enableCommunity={enableCommunity}
isShared={isShared}
isTotallyNormal={isTotallyNormal}
logo={logo}
renderLogin={renderLogin}
showComingSoon={showComingSoon}
onClickAbout={onClickAbout}
onClickAccountNav={onClickAccountNav}
onClickLogo={onClickLogo}
onCloseAccountNav={onCloseAccountNav}
onLogOut={onLogOut}
onOpenRegistration={onOpenRegistration}
onProjectTelemetryEvent={onProjectTelemetryEvent}
onSeeCommunity={onSeeCommunity}
onShare={onShare}
onStartSelectingFileUpload={onStartSelectingFileUpload}
onToggleLoginOpen={onToggleLoginOpen}
userOwnsProject={userOwnsProject}
username={username}
accountMenuOptions={accountMenuOptions}
/>}
{!menuBarHidden &&
<MenuRefProvider>
<MenuBar
accountNavOpen={accountNavOpen}
authorId={authorId}
authorThumbnailUrl={authorThumbnailUrl}
authorUsername={authorUsername}
canChangeLanguage={canChangeLanguage}
canChangeTheme={canChangeTheme}
canCreateCopy={canCreateCopy}
canCreateNew={canCreateNew}
canEditTitle={canEditTitle}
canManageFiles={canManageFiles}
canRemix={canRemix}
canSave={canSave}
canShare={canShare}
className={styles.menuBarPosition}
enableCommunity={enableCommunity}
isShared={isShared}
isTotallyNormal={isTotallyNormal}
logo={logo}
renderLogin={renderLogin}
showComingSoon={showComingSoon}
onClickAbout={onClickAbout}
onClickAccountNav={onClickAccountNav}
onClickLogo={onClickLogo}
onCloseAccountNav={onCloseAccountNav}
onLogOut={onLogOut}
onOpenRegistration={onOpenRegistration}
onProjectTelemetryEvent={onProjectTelemetryEvent}
onSeeCommunity={onSeeCommunity}
onShare={onShare}
onStartSelectingFileUpload={onStartSelectingFileUpload}
onToggleLoginOpen={onToggleLoginOpen}
userOwnsProject={userOwnsProject}
username={username}
accountMenuOptions={accountMenuOptions}
/>
</MenuRefProvider>
}
<Box className={boxStyles}>
<Box className={styles.flexWrapper}>
<Box className={styles.editorWrapper}>
Expand Down
120 changes: 106 additions & 14 deletions packages/scratch-gui/src/components/menu-bar/language-menu.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ 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 {closeLanguageMenu, languageMenuOpen, openLanguageMenu} from '../../reducers/menus.js';
import {selectLocale} from '../../reducers/locales.js';
import {MenuRefContext} from '../context-menu/menu-path-context.jsx';

import styles from './settings-menu.css';

Expand All @@ -20,9 +21,18 @@ class LanguageMenu extends React.PureComponent {
constructor (props) {
super(props);
bindAll(this, [
'handleKeyPress',
'handleKeyPressOpenMenu',
'handleMove',
'handleOnOpen',
'handleOnClose',
'setFocusedRef',
'setRef',
'handleMouseOver'
]);

this.state = {focusedIndex: -1};
this.itemRefs = Object.keys(locales).map(() => React.createRef());
}

componentDidUpdate (prevProps) {
Expand All @@ -32,26 +42,104 @@ class LanguageMenu extends React.PureComponent {
}
}

static contextType = MenuRefContext;

setRef (component) {
this.selectedRef = component;
}

handleKeyPress (e) {
if (this.context.isTopMenu(this.props.focusedRef)) {
this.handleKeyPressOpenMenu(e);
} else if (!this.context.isOpenMenu(this.props.focusedRef) && (e.key === ' ' || e.key === 'ArrowRight')) {
e.preventDefault();
this.handleOnOpen();
}
}

handleKeyPressOpenMenu (e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
this.handleMove(1);
}
if (e.key === 'ArrowUp') {
e.preventDefault();
this.handleMove(-1);
}

if (e.key === 'Enter') {
e.preventDefault();
this.props.onChangeLanguage(Object.keys(locales)[this.state.focusedIndex]);
this.handleOnClose();
}

if (e.key === 'ArrowLeft' || e.key === 'Escape') {
e.preventDefault();
this.handleOnClose();
}
}

handleMove (move) {
const newIndex = (this.state.focusedIndex + move + this.itemRefs.length) % this.itemRefs.length;
this.setState({focusedIndex: newIndex}, () => {
const ref = this.itemRefs[this.state.focusedIndex];
if (ref && ref.current) ref.current.focus();
});
}

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'});
this.setFocusedRef(this.selectedRef);
}
}

handleOnOpen () {
if (this.context.isOpenMenu(this.props.focusedRef)) return;

this.props.onRequestOpen();
this.setState({focusedIndex: Object.keys(locales).indexOf(this.props.currentLocale)}, () => {
this.setFocusedRef(this.itemRefs[this.state.focusedIndex]);
});

this.context.addInner(this.props.focusedRef);
}

handleOnClose () {
this.context.removeByRef(this.props.focusedRef);
this.setState({focusedIndex: -1}, () => {
this.setFocusedRef(this.props.focusedRef);
});
closeLanguageMenu();
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This appears to call closeLanguageMenu() directly instead of dispatching it. This should likely use the Redux dispatch mechanism or call an appropriate prop function to properly update state.

Suggested change
closeLanguageMenu();
this.props.dispatch(closeLanguageMenu());

Copilot uses AI. Check for mistakes.
}

setFocusedRef (component) {
this.focusedRef = component;
if (this.focusedRef && this.focusedRef.current) {
this.focusedRef.current.focus();
}
}

render () {
const {
currentLocale,
focusedRef,
isRtl,
onChangeLanguage
} = this.props;

return (
<MenuItem
expanded={this.props.menuOpen}
>
<MenuItem expanded={this.context.isOpenMenu(focusedRef)}>
<div
className={styles.option}
onClick={this.props.onRequestOpen}
onClick={this.handleOnOpen}
onMouseOver={this.handleMouseOver}
ref={focusedRef}
aria-label="Language Menu"
role="button"
tabIndex={-1}
onKeyDown={this.handleKeyPress}
>
<img
className={styles.icon}
Expand All @@ -71,27 +159,31 @@ class LanguageMenu extends React.PureComponent {
</div>
<Submenu
className={styles.languageSubmenu}
place={this.props.isRtl ? 'left' : 'right'}
place={isRtl ? 'left' : 'right'}
>
{
Object.keys(locales)
.map(locale => (
<MenuItem
.map((locale, index) => {
const isSelected = currentLocale === locale;

return (<MenuItem
key={locale}
className={styles.languageMenuItem}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => this.props.onChangeLanguage(locale)}
onClick={() => onChangeLanguage(locale)}
focusedRef={this.itemRefs[index]}
onParentKeyPress={this.handleKeyPress}
>
<img
className={classNames(styles.check, {
[styles.selected]: this.props.currentLocale === locale
[styles.selected]: isSelected
})}
src={check}
{...(this.props.currentLocale === locale && {ref: this.setRef})}
{...(isSelected && {ref: this.setRef})}
/>
{locales[locale].name}
</MenuItem>
))
</MenuItem>);
})
}
</Submenu>
</MenuItem>
Expand All @@ -101,8 +193,8 @@ class LanguageMenu extends React.PureComponent {

LanguageMenu.propTypes = {
currentLocale: PropTypes.string,
focusedRef: PropTypes.object,
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using PropTypes.object is discouraged. Consider using PropTypes.shape({ current: PropTypes.instanceOf(Element) }) for ref objects, or create a custom PropType validator.

Suggested change
focusedRef: PropTypes.object,
focusedRef: PropTypes.shape({current: PropTypes.instanceOf(Element)}),

Copilot uses AI. Check for mistakes.
isRtl: PropTypes.bool,
label: PropTypes.string,
menuOpen: PropTypes.bool,
onChangeLanguage: PropTypes.func,
onRequestCloseSettings: PropTypes.func,
Expand Down
Loading