-
Notifications
You must be signed in to change notification settings - Fork 142
[UEPR-462] Ensure "Stage" and "Target" editor regions are navigable through tab navigation #424
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: release/accessibility-improvements
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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' | ||
| }); | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| > | ||
| <img | ||
| className={classNames( | ||
|
|
@@ -35,6 +47,7 @@ const GreenFlagComponent = function (props) { | |
| ); | ||
| }; | ||
| GreenFlagComponent.propTypes = { | ||
| isFullScreen: PropTypes.bool, | ||
| active: PropTypes.bool, | ||
| className: PropTypes.string, | ||
| onClick: PropTypes.func.isRequired, | ||
|
|
||
| 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'; | ||
|
|
||
|
|
@@ -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: { | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we actually need to |
||
| } | ||
| }, [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 | ||
| > | ||
| <img | ||
| alt={intl.formatMessage(messages.unFullStageSizeMessage)} | ||
|
|
@@ -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> | ||
|
|
@@ -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}> | ||
|
|
@@ -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)} | ||
|
|
||
| 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} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could probably just use |
||
| {...(isFullScreen ? {'data-focusable': true} : {})} | ||
|
Comment on lines
+31
to
+33
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same nitpick as for the green flag button |
||
| > | ||
| <img | ||
| className={classNames( | ||
|
|
@@ -36,6 +49,7 @@ const StopAllComponent = function (props) { | |
| }; | ||
|
|
||
| StopAllComponent.propTypes = { | ||
| isFullScreen: PropTypes.bool, | ||
| active: PropTypes.bool, | ||
| className: PropTypes.string, | ||
| onClick: PropTypes.func.isRequired, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
| } | ||
| }); | ||
| }, []); | ||
|
Comment on lines
+52
to
+60
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
I'm fine with using 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 ( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import {useEffect, useRef} from 'react'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| /** | ||
| * 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); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}; | ||
| } | ||
There was a problem hiding this comment.
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 projectfor the accessibility label? As opposed toGreen Flagfor example?