diff --git a/packages/scratch-gui/src/components/controls/controls.jsx b/packages/scratch-gui/src/components/controls/controls.jsx index 12b2885c1f..0dfe6489ad 100644 --- a/packages/scratch-gui/src/components/controls/controls.jsx +++ b/packages/scratch-gui/src/components/controls/controls.jsx @@ -29,6 +29,7 @@ const Controls = function (props) { onGreenFlagClick, onStopAllClick, turbo, + isFullScreen, ...componentProps } = props; const intl = useIntl(); @@ -41,11 +42,13 @@ const Controls = function (props) { active={active} title={intl.formatMessage(messages.goTitle)} onClick={onGreenFlagClick} + isFullScreen={isFullScreen} /> {turbo ? ( @@ -55,6 +58,7 @@ const Controls = function (props) { }; Controls.propTypes = { + isFullScreen: PropTypes.bool, active: PropTypes.bool, className: PropTypes.string, onGreenFlagClick: PropTypes.func.isRequired, diff --git a/packages/scratch-gui/src/components/direction-picker/direction-picker.jsx b/packages/scratch-gui/src/components/direction-picker/direction-picker.jsx index 55c1a0dabd..afe0b58b83 100644 --- a/packages/scratch-gui/src/components/direction-picker/direction-picker.jsx +++ b/packages/scratch-gui/src/components/direction-picker/direction-picker.jsx @@ -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); @@ -51,15 +52,19 @@ const messages = defineMessages({ const DirectionPicker = props => { const intl = useIntl(); + + const {containerRef, popoverRef} = useFocusOutside(props.onClosePopover); + return ( + ( - +const Label = React.forwardRef((props, ref) => ( + {props.text} {props.children} -); +)); + +Label.displayName = 'Label'; Label.propTypes = { above: PropTypes.bool, diff --git a/packages/scratch-gui/src/components/green-flag/green-flag.jsx b/packages/scratch-gui/src/components/green-flag/green-flag.jsx index e399e037da..14dd4f5232 100644 --- a/packages/scratch-gui/src/components/green-flag/green-flag.jsx +++ b/packages/scratch-gui/src/components/green-flag/green-flag.jsx @@ -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' +}); + const GreenFlagComponent = function (props) { const { active, className, onClick, title, + isFullScreen, ...componentProps } = props; + + const intl = useIntl(); return ( { + if (isFullScreen) { + captureFocus(); + restrictFocusableElements(); + } else { + unrestrictFocusableElements(); + restoreFocus(); + } + }, [isFullScreen]); + let header = null; const onUpdateThumbnail = useCallback( @@ -112,6 +130,8 @@ const StageHeaderComponent = function (props) { className={styles.stageButton} onClick={onSetStageUnFull} onKeyPress={onKeyPress} + aria-label={intl.formatMessage(messages.unFullStageSizeMessage)} + data-focusable > - + {stageButton} @@ -163,7 +186,10 @@ const StageHeaderComponent = function (props) { header = ( - + {stageControls} @@ -179,6 +205,7 @@ const StageHeaderComponent = function (props) { ); } } Controls.propTypes = { + isFullScreen: PropTypes.bool, isStarted: PropTypes.bool.isRequired, projectRunning: PropTypes.bool.isRequired, turbo: PropTypes.bool.isRequired, diff --git a/packages/scratch-gui/src/contexts/modal-focus-context.jsx b/packages/scratch-gui/src/contexts/modal-focus-context.jsx index 79c7a9fe33..47e42d439e 100644 --- a/packages/scratch-gui/src/contexts/modal-focus-context.jsx +++ b/packages/scratch-gui/src/contexts/modal-focus-context.jsx @@ -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. @@ -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); + } + }); + }, []); + + 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 ( diff --git a/packages/scratch-gui/src/hooks/useFocusOutside.js b/packages/scratch-gui/src/hooks/useFocusOutside.js new file mode 100644 index 0000000000..44629722dc --- /dev/null +++ b/packages/scratch-gui/src/hooks/useFocusOutside.js @@ -0,0 +1,41 @@ +import {useEffect, useRef} from 'react'; + +/** + * 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); + 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}; +}