Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
cacb33e
chore: rewrote action menu and added hook to handle navigation
kbangelov Feb 2, 2026
4791d3b
chore: completed refocus on subelement logic and focus background lik…
kbangelov Feb 3, 2026
fb66c83
chore: jsdoc and refactoring
kbangelov Feb 3, 2026
0c94fba
chore: fixed focus + hover bug and updated integration test
kbangelov Feb 5, 2026
f25eb2b
chore: brought back old test
kbangelov Feb 5, 2026
e80ff2f
chore: test fix
kbangelov Feb 5, 2026
489159e
chore: fix the test by selecting editor
kbangelov Feb 5, 2026
a8c4cad
chore: made sound test work again
kbangelov Feb 6, 2026
bf0c57f
chore: fixed shift + tag bug artificially
kbangelov Feb 6, 2026
71d9e63
chore: refactoring logic back inside action menu
kbangelov Feb 16, 2026
a495352
chore: removed problem-causing onBlur
kbangelov Feb 16, 2026
8078f05
chore: remove unnecessary code
kbangelov Feb 16, 2026
d21a2bb
chore: addressed comments
kbangelov Feb 16, 2026
cd20b7d
chore: switched up the UX for the menu - doesn't focus on inside item…
kbangelov Feb 17, 2026
99efc85
chore: brought back old test
kbangelov Feb 18, 2026
aeb9212
chore: addressed comments
kbangelov Feb 18, 2026
70ef141
chore: brought back part of old comment
kbangelov Feb 18, 2026
221df16
chore: old comment
kbangelov Feb 18, 2026
383fc9c
chore: address copilot comments
kbangelov Feb 18, 2026
fc97862
chore: made keyboard logic change for arrow movement
kbangelov Feb 18, 2026
d1d4950
chore: wrote 3 unit tests for action menu
kbangelov Feb 20, 2026
d12b4af
chore: updated tests
kbangelov Feb 25, 2026
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
Expand Up @@ -29,7 +29,8 @@ button::-moz-focus-inner {
border: 0;
}

.button:hover {
.button:hover,
.button:focus {
background: $extensions-primary;
}

Expand Down
333 changes: 189 additions & 144 deletions packages/scratch-gui/src/components/action-menu/action-menu.jsx
Original file line number Diff line number Diff line change
@@ -1,152 +1,185 @@
import React, {useState, useRef, useEffect, useCallback} from 'react';
import PropTypes from 'prop-types';
import React from 'react';
import classNames from 'classnames';
import bindAll from 'lodash.bindall';
import ReactTooltip from 'react-tooltip';

import styles from './action-menu.css';
import {KEY} from '../../lib/navigation-keys';

const CLOSE_DELAY = 300; // ms

class ActionMenu extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'clickDelayer',
'handleClosePopover',
'handleToggleOpenState',
'handleTouchStart',
'handleTouchOutside',
'setButtonRef',
'setContainerRef'
]);
this.state = {
isOpen: false,
forceHide: false
};
this.mainTooltipId = `tooltip-${Math.random()}`;
}
componentDidMount () {
// Touch start on the main button is caught to trigger open and not click
this.buttonRef.addEventListener('touchstart', this.handleTouchStart);
// Touch start on document is used to trigger close if it is outside
document.addEventListener('touchstart', this.handleTouchOutside);
}
shouldComponentUpdate (newProps, newState) {
// This check prevents re-rendering while the project is updating.
// @todo check only the state and the title because it is enough to know
// if anything substantial has changed
// This is needed because of the sloppy way the props are passed as a new object,
// which should be refactored.
return newState.isOpen !== this.state.isOpen ||
newState.forceHide !== this.state.forceHide ||
newProps.title !== this.props.title;
}
componentWillUnmount () {
this.buttonRef.removeEventListener('touchstart', this.handleTouchStart);
document.removeEventListener('touchstart', this.handleTouchOutside);
}
handleClosePopover () {
this.closeTimeoutId = setTimeout(() => {
this.setState({isOpen: false});
this.closeTimeoutId = null;
}, CLOSE_DELAY);
}
handleToggleOpenState () {
const ActionMenu = ({
className,
img: mainImg,
title: mainTitle,
moreButtons,
tooltipPlace,
onClick
}) => {
const [forceHide, setForceHide] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);

const closeTimeoutRef = useRef(null);
const mainTooltipId = useRef(`tooltip-${Math.random()}`).current;
const containerRef = useRef(null);
const buttonRef = useRef(null);
const itemRefs = useRef([]);

const handleToggleOpenState = useCallback(() => {
// Mouse enter back in after timeout was started prevents it from closing.
if (this.closeTimeoutId) {
clearTimeout(this.closeTimeoutId);
this.closeTimeoutId = null;
} else if (!this.state.isOpen) {
this.setState({
isOpen: true,
forceHide: false
});

if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
} else if (!isExpanded) {
setIsExpanded(true);
setForceHide(false);
}
}
handleTouchOutside (e) {
if (this.state.isOpen && !this.containerRef.contains(e.target)) {
this.setState({isOpen: false});
ReactTooltip.hide();
}, [isExpanded]);

const handleTouchStart = useCallback(e => {
// Prevent this touch from becoming a click if menu is closed
if (!isExpanded) {
e.preventDefault();
handleToggleOpenState();
}
}, [isExpanded, handleToggleOpenState]);

useEffect(() => {
const buttonEl = buttonRef.current;
if (!buttonEl) return;

buttonEl.addEventListener('touchstart', handleTouchStart);
return () => {
buttonEl.removeEventListener('touchstart', handleTouchStart);
};
}, [handleTouchStart]);

// Handle clicks/touches outside to close menu
useEffect(() => {
const handleTouchOutside = e => {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setIsExpanded(false);
ReactTooltip.hide();
}
};

document.addEventListener('touchstart', handleTouchOutside);
return () => {
document.removeEventListener('touchstart', handleTouchOutside);
};
}, [containerRef, setIsExpanded]);

const focusItem = useCallback(item => {
if (item) {
item.focus();
}
}
clickDelayer (fn) {
}, []);

const handleMove = useCallback(direction => {
const items = itemRefs.current;
if (!items.length) return;

if (!items.includes(document.activeElement)) {
focusItem(direction === 1 ? items[0] : items[items.length - 1]);
return;
}

const currentIndex = items.indexOf(document.activeElement);
const nextIndex = (currentIndex + direction + items.length) % items.length;
focusItem(items[nextIndex]);
}, [itemRefs, focusItem]);

const handleKeyDown = useCallback(e => {
if (e.key === KEY.ARROW_DOWN || e.key === KEY.ARROW_UP) {
e.preventDefault();
if (!isExpanded) {
setIsExpanded(true);
}
const direction = e.key === KEY.ARROW_UP ? -1 : 1;
handleMove(direction);
} else if (e.key === KEY.TAB || e.key === KEY.ESCAPE) {
setIsExpanded(false);
focusItem(buttonRef.current);
}
}, [handleMove, isExpanded, setIsExpanded]);

const handleClosePopover = useCallback(() => {
closeTimeoutRef.current = setTimeout(() => {
setIsExpanded(false);
closeTimeoutRef.current = null;
}, CLOSE_DELAY);
}, []);

const clickDelayer = useCallback(
// Return a wrapped action that manages the menu closing.
// @todo we may be able to use react-transition for this in the future
// for now all this work is to ensure the menu closes BEFORE the
// (possibly slow) action is started.
return event => {
fn => (event => {
ReactTooltip.hide();
if (fn) fn(event);
// Blur the button so it does not keep focus after being clicked
// This prevents keyboard events from triggering the button
this.buttonRef.blur();
this.setState({forceHide: true, isOpen: false}, () => {
setTimeout(() => this.setState({forceHide: false}));
});
};
}
handleTouchStart (e) {
// Prevent this touch from becoming a click if menu is closed
if (!this.state.isOpen) {
e.preventDefault();
this.handleToggleOpenState();
}
}
setButtonRef (ref) {
this.buttonRef = ref;
}
setContainerRef (ref) {
this.containerRef = ref;
}
render () {
const {
className,
img: mainImg,
title: mainTitle,
moreButtons,
tooltipPlace,
onClick
} = this.props;

return (
<div
className={classNames(styles.menuContainer, className, {
[styles.expanded]: this.state.isOpen,
[styles.forceHidden]: this.state.forceHide
})}
ref={this.setContainerRef}
onMouseEnter={this.handleToggleOpenState}
onMouseLeave={this.handleClosePopover}
buttonRef.current?.blur();
setForceHide(true);
setIsExpanded(false);
setTimeout(() => setForceHide(false), 0);
}),
[]
);

return (
<div
className={classNames(styles.menuContainer, className, {
[styles.expanded]: isExpanded,
[styles.forceHidden]: forceHide
})}
onMouseEnter={handleToggleOpenState}
onMouseLeave={handleClosePopover}
onKeyDown={handleKeyDown}
onFocus={handleToggleOpenState}
onBlur={handleClosePopover}
ref={containerRef}
>
<button
aria-label={mainTitle}
className={classNames(styles.button, styles.mainButton)}
data-for={mainTooltipId}
data-tip={mainTitle}
onClick={clickDelayer(onClick)}
ref={buttonRef}
>
<button
aria-label={mainTitle}
className={classNames(styles.button, styles.mainButton)}
data-for={this.mainTooltipId}
data-tip={mainTitle}
ref={this.setButtonRef}
onClick={this.clickDelayer(onClick)}
>
<img
className={styles.mainIcon}
draggable={false}
src={mainImg}
/>
</button>
<ReactTooltip
className={styles.tooltip}
effect="solid"
id={this.mainTooltipId}
place={tooltipPlace || 'left'}
arrowColor="var(--tooltip-arrow-color)"
<img
className={styles.mainIcon}
draggable={false}
src={mainImg}
/>
<div className={styles.moreButtonsOuter}>
<ul className={styles.moreButtons}>
{(moreButtons || []).map(({img, title, onClick: handleClick,
fileAccept, fileChange, fileInput, fileMultiple}, keyId) => {
</button>
<ReactTooltip
className={styles.tooltip}
effect="solid"
id={mainTooltipId}
place={tooltipPlace || 'left'}
arrowColor="var(--tooltip-arrow-color)"
/>
<div className={styles.moreButtonsOuter}>
<ul className={styles.moreButtons}>
{(moreButtons || []).map(
(
{
img,
title,
onClick: handleClick,
fileAccept,
fileChange,
fileInput,
fileMultiple
},
keyId
) => {
const isComingSoon = !handleClick;
const hasFileInput = fileInput;
const tooltipId = `${this.mainTooltipId}-${title}`;
const tooltipId = `${mainTooltipId}-${title}`;
return (
<li key={`${tooltipId}-${keyId}`}>
<button
Expand All @@ -156,7 +189,11 @@ class ActionMenu extends React.Component {
})}
data-for={tooltipId}
data-tip={title}
onClick={hasFileInput ? handleClick : this.clickDelayer(handleClick)}
onClick={hasFileInput ? handleClick : clickDelayer(handleClick)}
tabIndex={-1}
ref={el => {
itemRefs.current[keyId] = el;
}}
>
<img
className={styles.moreIcon}
Expand Down Expand Up @@ -184,29 +221,37 @@ class ActionMenu extends React.Component {
/>
</li>
);
})}
</ul>
</div>
}
)}
</ul>
</div>
);
}
}
</div>
);
};

ActionMenu.propTypes = {
className: PropTypes.string,
img: PropTypes.string,
moreButtons: PropTypes.arrayOf(PropTypes.shape({
img: PropTypes.string,
title: PropTypes.node.isRequired,
onClick: PropTypes.func, // Optional, "coming soon" if no callback provided
fileAccept: PropTypes.string, // Optional, only for file upload
fileChange: PropTypes.func, // Optional, only for file upload
fileInput: PropTypes.func, // Optional, only for file upload
fileMultiple: PropTypes.bool // Optional, only for file upload
})),
moreButtons: PropTypes.arrayOf(
PropTypes.shape({
img: PropTypes.string,
title: PropTypes.node.isRequired,
onClick: PropTypes.func, // Optional, "coming soon" if no callback provided
fileAccept: PropTypes.string, // Optional, only for file upload
fileChange: PropTypes.func, // Optional, only for file upload
fileInput: PropTypes.func, // Optional, only for file upload
fileMultiple: PropTypes.bool // Optional, only for file upload
})
),
onClick: PropTypes.func.isRequired,
title: PropTypes.node.isRequired,
tooltipPlace: PropTypes.string
};

export default ActionMenu;
export default React.memo(ActionMenu, (prevProps, nextProps) =>
// This check prevents re-rendering while the project is updating.
// This is needed because of the sloppy way the props are passed as a new object,
// which should be refactored.
// Only re-render if the title changes
prevProps.title === nextProps.title
);
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,6 @@ const StageSelector = props => {
title: intl.formatMessage(messages.addBackdropFromSurprise),
img: surpriseIcon,
onClick: onSurpriseBackdropClick

}, {
title: intl.formatMessage(messages.addBackdropFromPaint),
img: paintIcon,
Expand Down
Loading
Loading