Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions packages/scratch-gui/src/components/controls/controls.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const Controls = function (props) {
onGreenFlagClick,
onStopAllClick,
turbo,
isFullScreen,
...componentProps
} = props;
const intl = useIntl();
Expand All @@ -41,11 +42,13 @@ const Controls = function (props) {
active={active}
title={intl.formatMessage(messages.goTitle)}
onClick={onGreenFlagClick}
isFullScreen={isFullScreen}
/>
<StopAll
active={active}
title={intl.formatMessage(messages.stopTitle)}
onClick={onStopAllClick}
isFullScreen={isFullScreen}
/>
{turbo ? (
<TurboMode />
Expand All @@ -55,6 +58,7 @@ const Controls = function (props) {
};

Controls.propTypes = {
isFullScreen: PropTypes.bool,
active: PropTypes.bool,
className: PropTypes.string,
onGreenFlagClick: PropTypes.func.isRequired,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import styles from './direction-picker.css';
import allAroundIcon from './icon--all-around.svg';
import leftRightIcon from './icon--left-right.svg';
import dontRotateIcon from './icon--dont-rotate.svg';
import useFocusOutside from '../../hooks/useFocusOutside.js';

const BufferedInput = BufferedInputHOC(Input);

Expand Down Expand Up @@ -51,15 +52,19 @@ const messages = defineMessages({

const DirectionPicker = props => {
const intl = useIntl();

const {containerRef, popoverRef} = useFocusOutside(props.onClosePopover);

return (
<Label
secondary
above={props.labelAbove}
text={directionLabel}
ref={containerRef}
>
<Popover
body={
<div>
<div ref={popoverRef}>
<Dial
direction={props.direction}
onChange={props.onChangeDirection}
Expand Down
11 changes: 8 additions & 3 deletions packages/scratch-gui/src/components/forms/label.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@ import React from 'react';

import styles from './label.css';

const Label = props => (
<label className={props.above ? styles.inputGroupColumn : styles.inputGroup}>
const Label = React.forwardRef((props, ref) => (
<label
ref={ref}
className={props.above ? styles.inputGroupColumn : styles.inputGroup}
>
<span className={props.secondary ? styles.inputLabelSecondary : styles.inputLabel}>
{props.text}
</span>
{props.children}
</label>
);
));

Label.displayName = 'Label';

Label.propTypes = {
above: PropTypes.bool,
Expand Down
13 changes: 13 additions & 0 deletions packages/scratch-gui/src/components/green-flag/green-flag.jsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,35 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import {defineMessage, useIntl} from 'react-intl';

import greenFlagIcon from './icon--green-flag.svg';
import styles from './green-flag.css';

const startProjectMessage = defineMessage({
id: 'gui.aria.startProjectButton',
defaultMessage: 'Start project',
description: 'accessibility label for start project button'
});
Comment on lines +9 to +13
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe we can run this by Tereza or someone just to make sure that we'd want to use Start project for the accessibility label? As opposed to Green Flag for example?


const GreenFlagComponent = function (props) {
const {
active,
className,
onClick,
title,
isFullScreen,
...componentProps
} = props;

const intl = useIntl();
return (
<button
className={styles.greenFlagButton}
onClick={onClick}
{...componentProps}
aria-label={intl.formatMessage(startProjectMessage)}
{...(isFullScreen ? {'data-focusable': true} : {})}
Comment on lines +31 to +32
Copy link
Contributor

Choose a reason for hiding this comment

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

Definitely just a nitpick, and totally fine to leave as-is, but usually the destructured ...componentProps go last 😅 That way we can override already-passed props if needed, for example aria-label.

>
<img
className={classNames(
Expand All @@ -35,6 +47,7 @@ const GreenFlagComponent = function (props) {
);
};
GreenFlagComponent.propTypes = {
isFullScreen: PropTypes.bool,
active: PropTypes.bool,
className: PropTypes.string,
onClick: PropTypes.func.isRequired,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {FormattedMessage, defineMessages, useIntl} from 'react-intl';
import PropTypes from 'prop-types';
import React, {useCallback} from 'react';
import React, {useCallback, useContext, useLayoutEffect} from 'react';
import {connect} from 'react-redux';
import VM from '@scratch/scratch-vm';

Expand All @@ -21,6 +21,7 @@ import styles from './stage-header.css';
import {storeProjectThumbnail} from '../../lib/store-project-thumbnail.js';
import dataURItoBlob from '../../lib/data-uri-to-blob.js';
import throttle from 'lodash.throttle';
import {ModalFocusContext} from '../../contexts/modal-focus-context.jsx';

const messages = defineMessages({
largeStageSizeMessage: {
Expand Down Expand Up @@ -73,6 +74,23 @@ const StageHeaderComponent = function (props) {
} = props;
const intl = useIntl();

const {
captureFocus,
restoreFocus,
restrictFocusableElements,
unrestrictFocusableElements
} = useContext(ModalFocusContext);

useLayoutEffect(() => {
if (isFullScreen) {
captureFocus();
restrictFocusableElements();
} else {
unrestrictFocusableElements();
restoreFocus();
Comment on lines +86 to +90
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we actually need to capture/restore the focus? The focused button should be visible both when you are in full screen, and when you are not, so shouldn't the focus be preserved anyway?

}
}, [isFullScreen]);

let header = null;

const onUpdateThumbnail = useCallback(
Expand Down Expand Up @@ -112,6 +130,8 @@ const StageHeaderComponent = function (props) {
className={styles.stageButton}
onClick={onSetStageUnFull}
onKeyPress={onKeyPress}
aria-label={intl.formatMessage(messages.unFullStageSizeMessage)}
data-focusable
>
<img
alt={intl.formatMessage(messages.unFullStageSizeMessage)}
Expand All @@ -129,7 +149,10 @@ const StageHeaderComponent = function (props) {
className={styles.stageMenuWrapper}
style={{width: stageDimensions.width}}
>
<Controls vm={vm} />
<Controls
isFullScreen={isFullScreen}
vm={vm}
/>
{stageButton}
</Box>
</Box>
Expand Down Expand Up @@ -163,7 +186,10 @@ const StageHeaderComponent = function (props) {
header = (
<Box className={styles.stageHeaderWrapper}>
<Box className={styles.stageMenuWrapper}>
<Controls vm={vm} />
<Controls
isFullScreen={isFullScreen}
vm={vm}
/>
<div className={styles.stageSizeRow}>
{stageControls}
<div className={styles.rightSection}>
Expand All @@ -179,6 +205,7 @@ const StageHeaderComponent = function (props) {
<Button
className={styles.stageButton}
onClick={onSetStageFull}
aria-label={intl.formatMessage(messages.fullStageSizeMessage)}
>
<img
alt={intl.formatMessage(messages.fullStageSizeMessage)}
Expand Down
14 changes: 14 additions & 0 deletions packages/scratch-gui/src/components/stop-all/stop-all.jsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,36 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import {defineMessage, useIntl} from 'react-intl';

import stopAllIcon from './icon--stop-all.svg';
import styles from './stop-all.css';

const stopProjectMessage = defineMessage({
id: 'gui.aria.stopProjectButton',
defaultMessage: 'Stop project',
description: 'accessibility label for stop project button'
});

const StopAllComponent = function (props) {
const {
active,
className,
onClick,
title,
isFullScreen,
...componentProps
} = props;

const intl = useIntl();
return (
<button
className={styles.stopAllButton}
onClick={onClick}
{...componentProps}
aria-label={intl.formatMessage(stopProjectMessage)}
aria-disabled={!active}
Copy link
Contributor

Choose a reason for hiding this comment

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

We could probably just use disabled and actually disable the button, instead of just changing the styles and adding aria-disabled? We probably should have done this when we initially switched to a button anyway 😅

{...(isFullScreen ? {'data-focusable': true} : {})}
Comment on lines +31 to +33
Copy link
Contributor

Choose a reason for hiding this comment

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

Same nitpick as for the green flag button

>
<img
className={classNames(
Expand All @@ -36,6 +49,7 @@ const StopAllComponent = function (props) {
};

StopAllComponent.propTypes = {
isFullScreen: PropTypes.bool,
active: PropTypes.bool,
className: PropTypes.string,
onClick: PropTypes.func.isRequired,
Expand Down
3 changes: 3 additions & 0 deletions packages/scratch-gui/src/containers/controls.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class Controls extends React.Component {
isStarted,
projectRunning,
turbo,
isFullScreen,
...props
} = this.props;
return (
Expand All @@ -44,12 +45,14 @@ class Controls extends React.Component {
turbo={turbo}
onGreenFlagClick={this.handleGreenFlagClick}
onStopAllClick={this.handleStopAllClick}
isFullScreen={isFullScreen}
/>
);
}
}

Controls.propTypes = {
isFullScreen: PropTypes.bool,
isStarted: PropTypes.bool.isRequired,
projectRunning: PropTypes.bool.isRequired,
turbo: PropTypes.bool.isRequired,
Expand Down
49 changes: 47 additions & 2 deletions packages/scratch-gui/src/contexts/modal-focus-context.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import PropTypes from 'prop-types';

export const ModalFocusContext = createContext(null);

const ALLOW_SELECTOR = '[data-focusable]';

/**
* A context provider that manages focus restoration strategies for modals.
*
* It keeps track of the element that was focused prior to a modal opening (`captureFocus`)
* and attempts to restore focus to that element when the modal closes (`restoreFocus`).
* It can make all other elements outside the modal unfocusable via tab (`restrictFocusableElements`)
* and return their original focusability (`unrestrictFocusableElements`).
*
* This uses a ref to store the DOM element, ensuring that focus restoration only occurs
* if the original element is still connected to the DOM.
Expand All @@ -29,12 +33,53 @@ export const ModalFocusProvider = ({children}) => {
}
}, []);

// We set all other elements to -1 so 'tab' can't access them
const makeUnfocusable = el => {
if (el.tabIndex >= 0) {
el.dataset.prevTabIndex = el.tabIndex;
el.tabIndex = -1;
}
};

// We restore their original 'tabIndex'
const restoreTabIndex = el => {
if (el.dataset.prevTabIndex) {
el.tabIndex = Number(el.dataset.prevTabIndex);
delete el.dataset.prevTabIndex;
}
};

const restrictFocusableElements = useCallback(() => {
const allElements = document.body.querySelectorAll('*');

allElements.forEach(el => {
if (!el.matches(ALLOW_SELECTOR)) {
makeUnfocusable(el);
}
});
}, []);
Comment on lines +52 to +60
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm, I like the idea of trapping focus inside the stage when it's in full screen, but making every single element have tabIndex=-1 feels a bit hacky to me... I've been thinking about other ways to achieve the same behavior (I am basically thinking out loud here, so sorry if my thoughts are a bit messy).

The main issue is that this isn't a real modal, but we want to treat it like one. I don't see using an actual dialog as a viable option though. But maybe instead of manually changing the tabIndex, we could make some sort of a trapFocus/restrictFocus helper that targets a specific container, listens to keydown events, and:

  • Collects a list of focusable items within the container (we could potentially reuse your existing data-focusable prop)
  • If focus is outside that list, move it back to the first focusable element
  • If the last focusable element is focused and Tab is pressed, loop focus back to the first
  • If the first element is focused and Shift + Tab is pressed, move focus to the last

I'm fine with using ModalFocusProvider for now and handling this logic here, but generally it might make sense to extract it on its own at some point, as I can see this potentially being used in other places as well, which are not necessarily modals. The benefits I see in this approach are that if you have multiple containers or areas which can trap focus, you can "specify" the target region, in which the focus is trapped. Right now, if any other element outside the Stage has data-focusable as a prop, we'd still be able to navigate it even when in fullScreen.

What do you think? I'd like to here @KManolov3's thoughts as well. There's something similar (although not entirely) implemented here, which I think could be helpful.


const unrestrictFocusableElements = useCallback(() => {
const allElements = document.body.querySelectorAll('*');

allElements.forEach(el => {
restoreTabIndex(el);
});
}, []);

const value = useMemo(
() => ({
captureFocus,
restoreFocus
restoreFocus,
restrictFocusableElements,
unrestrictFocusableElements
}),
[captureFocus, restoreFocus]
[
captureFocus,
restoreFocus,
restrictFocusableElements,
unrestrictFocusableElements
]
);

return (
Expand Down
41 changes: 41 additions & 0 deletions packages/scratch-gui/src/hooks/useFocusOutside.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {useEffect, useRef} from 'react';
Copy link
Contributor

Choose a reason for hiding this comment

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

To be honest, this feels a bit like an overkill to me, and I'm not a huge fan of introducing very custom and highly specific logic/hooks for this... I am worried it could become harder to maintain over time. Maybe a simpler approach would work, for example handling keydown events directly in the direction-picker component and closing the modal when Tab or Shift + Tab is pressed? Again, I'd like to hear @KManolov3's thoughts as well, but I think keeping it as simple as possible would be the better choice, at least for this.


/**
* Custom hook that detects focus moving outside of the given container
* and popover elements and calls onClose when that happens.
*
* Useful for closing modals, popovers, or dropdowns when focus leaves
* their interactive area (e.g. via keyboard navigation).
* @param {() => void} onClose
* Closing popover function when focus moves outside both the container and popover.
* @returns {object}
* - containerRef: reference to the container of the element that activates the popover,
* - popoverRef: reference to be attached to popover
* }}
*/
export default function useFocusOutside (onClose) {
const containerRef = useRef(null);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I am actually leaning more towards replacing this with inputRef or more generically openingElementRef that we apply to the input or whatever element activates the popover instead of the container including them both.

const popoverRef = useRef(null);

useEffect(() => {
const handleFocusIn = event => {
const target = event.target;
if (
(containerRef.current && containerRef.current.contains(target)) ||
(popoverRef.current && popoverRef.current.contains(target))
) {
return;
}

onClose();
};

document.addEventListener('focusin', handleFocusIn);

return () => {
document.removeEventListener('focusin', handleFocusIn);
};
}, [containerRef, onClose]);

return {containerRef, popoverRef};
}
Loading