From a2f039017d18635fb27d235256af83b795ae680c Mon Sep 17 00:00:00 2001 From: Krum Angelov Date: Mon, 15 Dec 2025 11:46:52 +0200 Subject: [PATCH 1/3] chore: initial commit - making elements focusable and react to enter --- packages/scratch-gui/src/components/menu-bar/menu-bar.jsx | 8 ++++++++ .../scratch-gui/src/components/menu-bar/settings-menu.jsx | 3 +++ 2 files changed, 11 insertions(+) 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 58f955a1bb..dd3275787e 100644 --- a/packages/scratch-gui/src/components/menu-bar/menu-bar.jsx +++ b/packages/scratch-gui/src/components/menu-bar/menu-bar.jsx @@ -289,6 +289,11 @@ class MenuBar extends React.Component { }; } handleKeyPress (event) { + if (event.key === 'Enter' || event.key === '') { + event.preventDefault(); + event.target.click(); + } + const modifier = bowser.mac ? event.metaKey : event.ctrlKey; if (modifier && event.key === 's') { this.props.onClickSave(); @@ -442,6 +447,9 @@ class MenuBar extends React.Component {
Scratch Date: Fri, 19 Dec 2025 15:50:05 +0200 Subject: [PATCH 2/3] feat: added some accessability with arrow logic --- .../context-menu/menu-path-context.jsx | 89 ++++++++ .../scratch-gui/src/components/gui/gui.jsx | 77 +++---- .../src/components/menu-bar/language-menu.jsx | 120 +++++++++-- .../src/components/menu-bar/menu-bar.jsx | 15 +- .../src/components/menu-bar/settings-menu.jsx | 183 ++++++++++++---- .../src/components/menu-bar/theme-menu.jsx | 202 +++++++++++++----- .../scratch-gui/src/components/menu/menu.jsx | 18 +- packages/scratch-gui/src/containers/gui.jsx | 1 + 8 files changed, 563 insertions(+), 142 deletions(-) create mode 100644 packages/scratch-gui/src/components/context-menu/menu-path-context.jsx diff --git a/packages/scratch-gui/src/components/context-menu/menu-path-context.jsx b/packages/scratch-gui/src/components/context-menu/menu-path-context.jsx new file mode 100644 index 0000000000..47121f88b0 --- /dev/null +++ b/packages/scratch-gui/src/components/context-menu/menu-path-context.jsx @@ -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); + // } + + 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 ( + + {this.props.children} + + ); + } +} + +MenuRefProvider.propTypes = { + children: PropTypes.node +}; diff --git a/packages/scratch-gui/src/components/gui/gui.jsx b/packages/scratch-gui/src/components/gui/gui.jsx index 1fa06119e2..2f81df9eed 100644 --- a/packages/scratch-gui/src/components/gui/gui.jsx +++ b/packages/scratch-gui/src/components/gui/gui.jsx @@ -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. @@ -252,42 +253,46 @@ const GUIComponent = props => { onRequestClose={onRequestCloseBackdropLibrary} /> ) : null} - {!menuBarHidden && } + {!menuBarHidden && + + + + } 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..ad2e7f93d4 100644 --- a/packages/scratch-gui/src/components/menu-bar/language-menu.jsx +++ b/packages/scratch-gui/src/components/menu-bar/language-menu.jsx @@ -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'; @@ -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) { @@ -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(); + } + + setFocusedRef (component) { + this.focusedRef = component; + if (this.focusedRef && this.focusedRef.current) { + this.focusedRef.current.focus(); } } render () { + const { + currentLocale, + focusedRef, + isRtl, + onChangeLanguage + } = this.props; + return ( - +
{ Object.keys(locales) - .map(locale => ( - { + const isSelected = currentLocale === locale; + + return ( this.props.onChangeLanguage(locale)} + onClick={() => onChangeLanguage(locale)} + focusedRef={this.itemRefs[index]} + onParentKeyPress={this.handleKeyPress} > {locales[locale].name} - - )) + ); + }) } @@ -101,8 +193,8 @@ class LanguageMenu extends React.PureComponent { LanguageMenu.propTypes = { currentLocale: PropTypes.string, + focusedRef: PropTypes.object, isRtl: PropTypes.bool, - label: PropTypes.string, menuOpen: PropTypes.bool, onChangeLanguage: PropTypes.func, onRequestCloseSettings: PropTypes.func, 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 dd3275787e..28892afbbc 100644 --- a/packages/scratch-gui/src/components/menu-bar/menu-bar.jsx +++ b/packages/scratch-gui/src/components/menu-bar/menu-bar.jsx @@ -289,7 +289,7 @@ class MenuBar extends React.Component { }; } handleKeyPress (event) { - if (event.key === 'Enter' || event.key === '') { + if (event.key === 'Enter') { event.preventDefault(); event.target.click(); } @@ -474,6 +474,9 @@ class MenuBar extends React.Component { [styles.active]: this.props.fileMenuOpen })} onClick={this.props.onClickFile} + aria-label="File Menu" + role="button" + tabIndex={0} > @@ -544,6 +547,9 @@ class MenuBar extends React.Component { [styles.active]: this.props.editMenuOpen })} onClick={this.props.onClickEdit} + role="button" + aria-label="Edit Menu" + tabIndex={0} > @@ -596,6 +602,9 @@ class MenuBar extends React.Component { [styles.active]: this.props.modeMenuOpen })} onClick={this.props.onClickMode} + role="button" + aria-label="Mode" + tabIndex={0} >
( -
- - - - - - { + this.setFocusedRef(this.itemRefs[0]); + }); + } + } + + static contextType = MenuRefContext; + + handleOnClose () { + this.context.removeByRef(this.settingsRef); + this.props.onRequestClose(); + this.setState({focusedIndex: -1}); + } + + handleOnOpen () { + if (this.context.isOpenMenu(this.settingsRef)) return; + + this.setState({focusedIndex: 0}, () => { + this.props.onRequestOpen(); + this.context.addInner(this.settingsRef); + this.setFocusedRef(this.itemRefs[0]); + }); + } + + setFocusedRef (component) { + this.focusedRef = component; + if (this.focusedRef && this.focusedRef.current) { + this.focusedRef.current.focus(); + } + } + + handleKeyPress (e) { + if (e.key === 'Tab') { + this.handleOnClose(); + } + + if (this.context.isTopMenu(this.settingsRef)) { + this.handleKeyPressOpenMenu(e); + } else if (!this.context.isOpenMenu(this.settingsRef) && (e.key === ' ' || e.key === 'ArrowRight')) { + e.preventDefault(); + this.handleOnOpen(); + } + } + + handleKeyPressOpenMenu (e) { + if (e.key === 'ArrowLeft' || e.key === 'Escape') { + e.preventDefault(); + this.handleOnClose(); + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + this.handleMove(-1); + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + this.handleMove(1); + } + } + + handleMove (direction) { + const nextIndex = + (this.state.focusedIndex + direction + this.itemRefs.length) % this.itemRefs.length; + this.setState({focusedIndex: nextIndex}, () => { + this.setFocusedRef(this.itemRefs[nextIndex]); + }); + } + + render () { + const { + canChangeLanguage, + canChangeTheme, + isRtl, + onRequestClose, + settingsMenuOpen + } = this.props; + + return (
- - {canChangeLanguage && } - {canChangeTheme && } - - -
-); + + + + + + + + {canChangeLanguage && } + {canChangeTheme && } + + +
); + } +}; SettingsMenu.propTypes = { canChangeLanguage: PropTypes.bool, diff --git a/packages/scratch-gui/src/components/menu-bar/theme-menu.jsx b/packages/scratch-gui/src/components/menu-bar/theme-menu.jsx index e9ce24f1de..5aef101be1 100644 --- a/packages/scratch-gui/src/components/menu-bar/theme-menu.jsx +++ b/packages/scratch-gui/src/components/menu-bar/theme-menu.jsx @@ -1,5 +1,6 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; +import bindAll from 'lodash.bindall'; import React from 'react'; import {FormattedMessage} from 'react-intl'; import {connect} from 'react-redux'; @@ -8,8 +9,9 @@ import check from './check.svg'; import {MenuItem, Submenu} from '../menu/menu.jsx'; import {DEFAULT_THEME, HIGH_CONTRAST_THEME, themeMap} from '../../lib/themes'; import {persistTheme} from '../../lib/themes/themePersistance'; -import {openThemeMenu, themeMenuOpen} from '../../reducers/menus.js'; +import {openThemeMenu, closeThemeMenu} from '../../reducers/menus.js'; import {setTheme} from '../../reducers/theme.js'; +import {MenuRefContext} from '../context-menu/menu-path-context.jsx'; import styles from './settings-menu.css'; @@ -19,7 +21,11 @@ const ThemeMenuItem = props => { const themeInfo = themeMap[props.theme]; return ( - +
{ ThemeMenuItem.propTypes = { isSelected: PropTypes.bool, onClick: PropTypes.func, - theme: PropTypes.string + theme: PropTypes.string, + focusedRef: PropTypes.object, + onParentKeyPress: PropTypes.func }; -const ThemeMenu = ({ - isRtl, - menuOpen, - onChangeTheme, - onRequestOpen, - theme -}) => { - const enabledThemes = [DEFAULT_THEME, HIGH_CONTRAST_THEME]; - const themeInfo = themeMap[theme]; +class ThemeMenu extends React.PureComponent { + constructor (props) { + super(props); + bindAll(this, [ + 'handleKeyPress', + 'handleKeyPressOpenMenu', + 'handleMove', + 'handleOnOpen', + 'handleOnClose', + 'setFocusedRef', + 'setRef' + ]); - return ( - -
React.createRef()); + } + + 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.onChangeTheme(this.enabledThemes[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(); + }); + } + + handleOnOpen () { + if (this.context.isTopMenu(this.props.focusedRef)) return; + + this.props.onRequestOpen(); + this.setState({focusedIndex: 0}, () => { + 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); + }); + closeThemeMenu(); + } + + setFocusedRef (component) { + this.focusedRef = component; + if (this.focusedRef && this.focusedRef.current) { + this.focusedRef.current.focus(); + } + } + + render () { + const { + focusedRef, + isRtl, + onChangeTheme, + theme + } = this.props; + + const themeInfo = themeMap[theme]; + + return ( + - - - + - - -
- - {enabledThemes.map(enabledTheme => ( - onChangeTheme(enabledTheme)} - theme={enabledTheme} - />) - )} - -
- ); -}; + + + + +
+ + {this.enabledThemes.map((enabledTheme, index) => ( + onChangeTheme(enabledTheme)} + theme={enabledTheme} + focusedRef={this.itemRefs[index]} + onParentKeyPress={this.handleKeyPress} + />) + )} + +
+ ); + } +} ThemeMenu.propTypes = { + focusedRef: PropTypes.object, isRtl: PropTypes.bool, - menuOpen: PropTypes.bool, onChangeTheme: PropTypes.func, // eslint-disable-next-line react/no-unused-prop-types onRequestCloseSettings: PropTypes.func, @@ -99,7 +206,6 @@ ThemeMenu.propTypes = { const mapStateToProps = state => ({ isRtl: state.locales.isRtl, - menuOpen: themeMenuOpen(state), theme: state.scratchGui.theme.theme }); diff --git a/packages/scratch-gui/src/components/menu/menu.jsx b/packages/scratch-gui/src/components/menu/menu.jsx index 5be46a32e3..175c90a370 100644 --- a/packages/scratch-gui/src/components/menu/menu.jsx +++ b/packages/scratch-gui/src/components/menu/menu.jsx @@ -32,7 +32,6 @@ MenuComponent.propTypes = { place: PropTypes.oneOf(['left', 'right']) }; - const Submenu = ({children, className, place, ...props}) => (
(
  • {children}
  • ); MenuItem.propTypes = { + ariaLabel: PropTypes.string, + ariaRole: PropTypes.string, children: PropTypes.node, className: PropTypes.string, expanded: PropTypes.bool, - onClick: PropTypes.func + onClick: PropTypes.func, + focusedRef: PropTypes.object, + onParentKeyPress: PropTypes.func }; diff --git a/packages/scratch-gui/src/containers/gui.jsx b/packages/scratch-gui/src/containers/gui.jsx index d84fab6f7f..375790d47b 100644 --- a/packages/scratch-gui/src/containers/gui.jsx +++ b/packages/scratch-gui/src/containers/gui.jsx @@ -148,6 +148,7 @@ GUI.propTypes = { }; GUI.defaultProps = { + // isTotallyNormal: true, - for testing only isTotallyNormal: false, onStorageInit: () => {}, onProjectLoaded: () => {}, From 34b18dfa9331bbedfca38323e6bf20478ae6b248 Mon Sep 17 00:00:00 2001 From: Krum Angelov Date: Fri, 19 Dec 2025 17:28:47 +0200 Subject: [PATCH 3/3] chore: addressed copilot stuff --- .../src/components/context-menu/menu-path-context.jsx | 5 ----- .../src/components/menu-bar/language-menu.jsx | 8 +++++--- .../scratch-gui/src/components/menu-bar/menu-bar.jsx | 2 +- .../src/components/menu-bar/settings-menu.jsx | 2 +- .../scratch-gui/src/components/menu-bar/theme-menu.jsx | 10 ++++++---- packages/scratch-gui/src/components/menu/menu.jsx | 2 +- 6 files changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/scratch-gui/src/components/context-menu/menu-path-context.jsx b/packages/scratch-gui/src/components/context-menu/menu-path-context.jsx index 47121f88b0..2333f51c9f 100644 --- a/packages/scratch-gui/src/components/context-menu/menu-path-context.jsx +++ b/packages/scratch-gui/src/components/context-menu/menu-path-context.jsx @@ -60,10 +60,6 @@ export class MenuRefProvider extends React.Component { this.setState({openRefs: []}); } - // printChain () { - // console.log(this.state.openRefs); - // } - render () { const value = { openRefs: this.state.openRefs, @@ -73,7 +69,6 @@ export class MenuRefProvider extends React.Component { removeInner: this.removeInner, removeAll: this.removeAll, removeByRef: this.removeByRef - // printChain: this.printChain }; return ( 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 ad2e7f93d4..2de5339149 100644 --- a/packages/scratch-gui/src/components/menu-bar/language-menu.jsx +++ b/packages/scratch-gui/src/components/menu-bar/language-menu.jsx @@ -193,12 +193,13 @@ class LanguageMenu extends React.PureComponent { LanguageMenu.propTypes = { currentLocale: PropTypes.string, - focusedRef: PropTypes.object, + focusedRef: PropTypes.shape({current: PropTypes.instanceOf(Element)}), isRtl: PropTypes.bool, menuOpen: PropTypes.bool, onChangeLanguage: PropTypes.func, onRequestCloseSettings: PropTypes.func, - onRequestOpen: PropTypes.func + onRequestOpen: PropTypes.func, + onRequestClose: PropTypes.func }; const mapStateToProps = state => ({ @@ -213,7 +214,8 @@ const mapDispatchToProps = (dispatch, ownProps) => ({ dispatch(selectLocale(locale)); ownProps.onRequestCloseSettings(); }, - onRequestOpen: () => dispatch(openLanguageMenu()) + onRequestOpen: () => dispatch(openLanguageMenu()), + onRequestClose: () => dispatch(closeLanguageMenu()) }); export default connect( diff --git a/packages/scratch-gui/src/components/menu-bar/menu-bar.jsx b/packages/scratch-gui/src/components/menu-bar/menu-bar.jsx index 28892afbbc..180a818d9b 100644 --- a/packages/scratch-gui/src/components/menu-bar/menu-bar.jsx +++ b/packages/scratch-gui/src/components/menu-bar/menu-bar.jsx @@ -449,7 +449,7 @@ class MenuBar extends React.Component { Scratch { this.setFocusedRef(this.props.focusedRef); }); - closeThemeMenu(); + this.props.onRequestClose(); } setFocusedRef (component) { @@ -195,12 +195,13 @@ class ThemeMenu extends React.PureComponent { } ThemeMenu.propTypes = { - focusedRef: PropTypes.object, + focusedRef: PropTypes.shape({current: PropTypes.instanceOf(Element)}), isRtl: PropTypes.bool, onChangeTheme: PropTypes.func, // eslint-disable-next-line react/no-unused-prop-types onRequestCloseSettings: PropTypes.func, onRequestOpen: PropTypes.func, + onRequestClose: PropTypes.func, theme: PropTypes.string }; @@ -215,7 +216,8 @@ const mapDispatchToProps = (dispatch, ownProps) => ({ ownProps.onRequestCloseSettings(); persistTheme(theme); }, - onRequestOpen: () => dispatch(openThemeMenu()) + onRequestOpen: () => dispatch(openThemeMenu()), + onRequestClose: () => dispatch(closeThemeMenu()) }); export default connect( diff --git a/packages/scratch-gui/src/components/menu/menu.jsx b/packages/scratch-gui/src/components/menu/menu.jsx index 175c90a370..523d6946c9 100644 --- a/packages/scratch-gui/src/components/menu/menu.jsx +++ b/packages/scratch-gui/src/components/menu/menu.jsx @@ -93,7 +93,7 @@ MenuItem.propTypes = { className: PropTypes.string, expanded: PropTypes.bool, onClick: PropTypes.func, - focusedRef: PropTypes.object, + focusedRef: PropTypes.shape({current: PropTypes.instanceOf(Element)}), onParentKeyPress: PropTypes.func };