Skip to content
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

Custom select control: don't announce external value changes #22815

Merged
merged 8 commits into from
Jun 18, 2020

Conversation

tellthemachines
Copy link
Contributor

Description

While testing #22453 I found that announcements of switch between Navigation and Edit modes are not being read out by VoiceOver on Paragraph and Heading blocks. They're working as expected on other block types.

The issue is with the useSelect hook in CustomSelectControl being passed the current value of the control. useSelect generates an aria-live announcement every time the value changes, or every time CustomSelectControl re-renders. Because the switch between modes causes the sidebar to re-render, that announcement is triggered for FontSizePicker, and it overrides the mode switch announcement.

My change here is to bypass useSelect and use the current value prop directly for populating the toggle button and styling the selected item. This changes nothing in the visual functionality, and has the extra benefit of stopping change announcements for the Preset size field whenever the Custom font size field or the Reset buttons are used. (Currently, changing the size in the Custom field with the up and down arrows causes "Custom has been selected" to be announced at every change, which can become quite annoying.)

How has this been tested?

Tested across browsers; especially with Safari + VoiceOver on mac OS, and Firefox + NVDA on Windows.

Screenshots

Types of changes

Bug fix (non-breaking change which fixes an issue)

Checklist:

  • My code is tested.
  • My code follows the WordPress code style.
  • My code follows the accessibility standards.
  • My code has proper inline documentation.
  • I've included developer documentation if appropriate.
  • I've updated all React Native files affected by any refactorings/renamings in this PR.

@github-actions
Copy link

github-actions bot commented Jun 2, 2020

Size Change: -7.85 kB (0%)

Total Size: 1.13 MB

Filename Size Change
build/annotations/index.js 3.62 kB +1 B
build/autop/index.js 2.83 kB -1 B
build/block-directory/index.js 7.26 kB +45 B (0%)
build/block-directory/style-rtl.css 955 B +63 B (6%) 🔍
build/block-directory/style.css 955 B +63 B (6%) 🔍
build/block-editor/index.js 106 kB -160 B (0%)
build/block-editor/style-rtl.css 10.7 kB -1.39 kB (12%) 👏
build/block-editor/style.css 10.7 kB -1.39 kB (12%) 👏
build/block-library/editor-rtl.css 7.85 kB -31 B (0%)
build/block-library/editor.css 7.86 kB -30 B (0%)
build/block-library/index.js 129 kB +565 B (0%)
build/block-library/style-rtl.css 8.02 kB +65 B (0%)
build/block-library/style.css 8.02 kB +64 B (0%)
build/block-library/theme-rtl.css 749 B +65 B (8%) 🔍
build/block-library/theme.css 751 B +65 B (8%) 🔍
build/blocks/index.js 48.1 kB +6 B (0%)
build/components/index.js 196 kB +229 B (0%)
build/components/style-rtl.css 15.9 kB -3.59 kB (22%) 🎉
build/components/style.css 15.9 kB -3.6 kB (22%) 🎉
build/compose/index.js 9.6 kB +287 B (2%)
build/core-data/index.js 11.4 kB -4 B (0%)
build/data-controls/index.js 1.29 kB -1 B
build/data/index.js 8.44 kB -4 B (0%)
build/date/index.js 5.47 kB -2 B (0%)
build/dom-ready/index.js 569 B +1 B
build/dom/index.js 3.17 kB +1 B
build/edit-navigation/style-rtl.css 1.04 kB +64 B (6%) 🔍
build/edit-navigation/style.css 1.04 kB +66 B (6%) 🔍
build/edit-post/index.js 303 kB +810 B (0%)
build/edit-post/style-rtl.css 5.6 kB +4 B (0%)
build/edit-post/style.css 5.6 kB +5 B (0%)
build/edit-site/index.js 16.6 kB -5 B (0%)
build/edit-site/style-rtl.css 3.13 kB +173 B (5%) 🔍
build/edit-site/style.css 3.13 kB +173 B (5%) 🔍
build/edit-widgets/style-rtl.css 2.54 kB +133 B (5%) 🔍
build/edit-widgets/style.css 2.54 kB +132 B (5%) 🔍
build/editor/editor-styles-rtl.css 486 B +63 B (12%) ⚠️
build/editor/editor-styles.css 487 B +64 B (13%) ⚠️
build/editor/index.js 44.8 kB -5 B (0%)
build/editor/style-rtl.css 3.82 kB -444 B (11%) 👏
build/editor/style.css 3.82 kB -444 B (11%) 👏
build/element/index.js 4.65 kB +4 B (0%)
build/format-library/index.js 7.72 kB -2 B (0%)
build/format-library/style-rtl.css 561 B +59 B (10%) ⚠️
build/format-library/style.css 562 B +60 B (10%) ⚠️
build/is-shallow-equal/index.js 711 B +1 B
build/keyboard-shortcuts/index.js 2.51 kB -2 B (0%)
build/list-reusable-blocks/style-rtl.css 537 B +311 B (57%) 🆘
build/list-reusable-blocks/style.css 537 B +311 B (57%) 🆘
build/media-utils/index.js 5.29 kB +2 B (0%)
build/notices/index.js 1.79 kB -2 B (0%)
build/nux/index.js 3.4 kB -1 B
build/nux/style-rtl.css 681 B +65 B (9%) 🔍
build/nux/style.css 676 B +63 B (9%) 🔍
build/plugins/index.js 2.56 kB +3 B (0%)
build/primitives/index.js 1.5 kB -2 B (0%)
build/priority-queue/index.js 788 B -1 B
build/redux-routine/index.js 2.85 kB +2 B (0%)
build/rich-text/index.js 14 kB -816 B (5%)
build/server-side-render/index.js 2.68 kB -4 B (0%)
build/url/index.js 4.06 kB +1 B
build/viewport/index.js 1.85 kB -1 B
build/warning/index.js 1.14 kB -1 B
ℹ️ View Unchanged
Filename Size Change
build/a11y/index.js 1.14 kB 0 B
build/api-fetch/index.js 3.4 kB 0 B
build/blob/index.js 620 B 0 B
build/block-serialization-default-parser/index.js 1.88 kB 0 B
build/block-serialization-spec-parser/index.js 3.1 kB 0 B
build/deprecated/index.js 772 B 0 B
build/edit-navigation/index.js 8.26 kB 0 B
build/edit-widgets/index.js 9.34 kB 0 B
build/escape-html/index.js 733 B 0 B
build/hooks/index.js 2.13 kB 0 B
build/html-entities/index.js 622 B 0 B
build/i18n/index.js 3.56 kB 0 B
build/keycodes/index.js 1.94 kB 0 B
build/list-reusable-blocks/index.js 3.12 kB 0 B
build/shortcode/index.js 1.7 kB 0 B
build/token-list/index.js 1.28 kB 0 B
build/wordcount/index.js 1.17 kB 0 B

compressed-size-action

} = useSelect( {
initialSelectedItem: items[ 0 ],
items,
itemToString,
onSelectedItemChange,
selectedItem: _selectedItem,
stateReducer,
} );

Copy link
Contributor

Choose a reason for hiding this comment

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

Aren't there other a11y props that need to be set on the selected item? This won't let the hook do it for the initial state when using this in a controlled manner. It will think items[ 0 ] is always the selected item.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for spotting that! I've fixed it so that all the attributes are in the correct places again, and all keyboard functionality is restored. The one thing I'm not super happy with is that, if a new value is set elsewhere, immediately reopening the dropdown will show the previous value incorrectly highlighted. I couldn't find a good workaround for that, but it seems to me less of an issue than what this PR is trying to fix 😅
Ultimately I think the best solution would be for Downshift to introduce an option to disable its aria-live announcements altogether, so we can use our custom ones and avoid clashes with other components (cc @silviuaavram would be great to have your opinion on this). Otherwise, if we find ourselves having to reimplement a ton of logic on our side, it might be easier to build our own. Happy to hear other ideas!

@@ -22,6 +22,12 @@ const stateReducer = (
{ selectedItem },
{ type, changes, props: { items } }
) => {
const selectedItemIndex = items.findIndex(
( item ) => item.key === selectedItem.key
Copy link
Contributor

Choose a reason for hiding this comment

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

When are the items here not referentially equal? I ask because other code relies on it below.

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 haven't really looked at what Downshift is doing, but suspect they are creating a selectedItem object at some point, because these stopped being referentially equal when I removed _selectedItem from the arguments passed to useSelect. Where do you reckon this could cause breakage?

Copy link
Contributor

Choose a reason for hiding this comment

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

https://github.com/WordPress/gutenberg/pull/22815/files#diff-406f1cf3cfd36939ce9e9607a5c408d2R152-R154

There, for example. We should stick to one way of comparing values to avoid inconsistencies.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Got it, fixed!

@afercia
Copy link
Contributor

afercia commented Jun 5, 2020

Ahh good catch @tellthemachines thanks! 🙂

Yeah, unnecessary re-renderings can produce very odd consequences for keyboard users and assistive technology. That's the reason why the accessibility team recommended since day 1 to avoid them as much as possible. The first recommendation dates to the day where a JS library for Gutenberg was being discussed more than 3 years ago 😄

Copy link
Contributor

@enriquesanchez enriquesanchez left a comment

Choose a reason for hiding this comment

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

Hi @tellthemachines!

Tested on Safari + VO and it works great. I was able to hear the current mode announcement on several types of blocks, including Headings and Parahgraphs 👍

} ) {
const valueIndex = items.findIndex( ( item ) => item.key === value.key );
Copy link
Contributor

Choose a reason for hiding this comment

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

This will throw if the input is not controlled. value wouldn't be defined.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Whoopsies, forgot about that!

@@ -122,7 +124,7 @@ export default function CustomSelectControl( {
isSmall: true,
} ) }
>
{ itemToString( selectedItem ) }
{ itemToString( value ) }
Copy link
Contributor

Choose a reason for hiding this comment

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

This won't work if the value prop is not used.

@@ -146,8 +148,9 @@ export default function CustomSelectControl( {
),
style: item.style,
} ) }
aria-selected={ index === valueIndex }
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should pass this to getItemProps.

>
{ item === selectedItem && (
{ item.key === value.key && (
Copy link
Contributor

Choose a reason for hiding this comment

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

Same as the other two usages of value. We need to use the selectedItem returned from useSelect.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hrm, because we're no longer telling useSelect about value updates, its selectedItem won't reflect the most recent value if that value is change elsewhere (as in the case of the multiple font-size setting options), so instead I'm checking whether a value has been set, and selecting the first item on the list if not (which I think would be expected behaviour in the case of an uncontrolled component).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok, I've pushed my changes now 😄

Copy link
Contributor

Choose a reason for hiding this comment

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

If the uncontrolled component updates, we can't keep selecting the first item in the list. We need to let Downshift tell us which item is selected.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh, good catch. It should be working now (tested with the storybook component).

@tellthemachines tellthemachines force-pushed the fix/custom-select-voiceover-issue branch from b7af0bc to 6c6297e Compare June 12, 2020 05:30
@@ -69,14 +76,12 @@ export default function CustomSelectControl( {
highlightedIndex,
selectedItem,
} = useSelect( {
initialSelectedItem: items[ 0 ],
initialSelectedItem: items[ valueIndex >= 0 ? valueIndex : 0 ],
items,
itemToString,
onSelectedItemChange,
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
onSelectedItemChange,
onSelectedItemChange,
selectedItem: valueIndex >= 0 ? items[ valueIndex ] : undefined

Downshift needs to be aware when the item really does change.

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'm afraid that's the one thing we can't do, because it causes Downshift to announce changes to the selection whenever the component re-renders. Apart from the minor issue of the highlight not always matching the selected item (which we may be able to fix by leveraging :focus styles), is there anything else that won't work with these changes? Otherwise I think we'll have to use a different library, or implement our own solution.

Copy link
Contributor

Choose a reason for hiding this comment

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

If the component rerenders and value is referentially equal, then it shouldn't be announcing anything, right? I think the issue is that the value is being recreated on render.

Have you tried memoizing getSelectOptions in the font size picker?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm, I haven't. But that still won't solve the problem of changes being announced whenever the value changes outside of the component, e.g. if I select a random value in the font size number input, it announces "custom has been selected".

Copy link
Contributor

Choose a reason for hiding this comment

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

I select a random value in the font size number input, it announces "custom has been selected".

It would only happen once when the value is switched to custom. Is that still worth avoiding?

Not passing Downshift the value means that when it changes elsewhere, Downshift won't know about it. If I understand how it works correctly, this means that all the prop getters will return props based on an outdated selected item. E.g., entering a custom value would change the rendered label because you are getting the value manually for that. Still, all the prop getters won't know about it internally so that they can return the relevant props.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If I understand how it works correctly, this means that all the prop getters will return props based on an outdated selected item.

Yeah, that's why the highlighting isn't working correctly when the value is updated elsewhere. With all the other props, I've managed to work around.

I'll try memoizing and if it solves the main issue, it's probably good enough for now. But we should revisit this later, as having that unexpected announcement when using the number input isn't really great.

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 add a prop to "silence" the announcement in those cases?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Do you have anything specific in mind? There doesn't seem to be a way of silencing the announcement in Downshift, and I can only think of extremely dodgy solutions to do it on our side 😅 (like removing Downshift's aria-live container or trying to override it with an empty speak - not sure that'd even work btw)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Update: memoizing getSelectOptions works for the announcement when switching editor modes, so that's the main issue solved ✅

Copy link
Contributor

Choose a reason for hiding this comment

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

(like removing Downshift's aria-live container or trying to override it with an empty speak - not sure that'd even work btw)

That was my line of thinking as well, haha.

works for the announcement when switching editor modes, so that's the main issue solved

Awesome!

) {
delete menuProps[ 'aria-activedescendant' ];
}
const selectedItemValue = value ? value : selectedItem;
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
const selectedItemValue = value ? value : selectedItem;
const selectedItemValue = selectedItem;

Copy link
Contributor

@epiqueras epiqueras left a comment

Choose a reason for hiding this comment

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

Can we memoize with useMemo instead? That way, it's cleaned up when the component unmounts, and we don't just leak memory.

@tellthemachines
Copy link
Contributor Author

Can we memoize with useMemo instead? That way, it's cleaned up when the component unmounts, and we don't just leak memory.

I didn't do that because there's a condition right before getSelectOptions gets called. With useMemo we'd have to move that check inside getSelectOptions and then check its value again after calling it. I can still do it if you reckon it's worth it to avoid the memory leak?

@epiqueras
Copy link
Contributor

Yeah, definitely. Especially because this component can be used in multiple places.

@tellthemachines tellthemachines merged commit cbef072 into master Jun 18, 2020
@tellthemachines tellthemachines deleted the fix/custom-select-voiceover-issue branch June 18, 2020 21:19
@github-actions github-actions bot added this to the Gutenberg 8.4 milestone Jun 18, 2020
@silviuaavram
Copy link

I am probably late to the party, but couldn't this issue be fixed by controlling getA11yStatusMessage / getA11ySelectionMessage https://github.com/downshift-js/downshift/tree/master/src/hooks/useSelect#geta11ystatusmessage ?

The default messaged generated provide information such as the number of options / which option has been selected in the select, so I am trusting these are important from the a11y point of view. I did not follow the whole discussion however, but if I can help let me know!

@tellthemachines
Copy link
Contributor Author

Thanks @silviuaavram ! What we need to do is disable the message whenever the update to selectedItem comes from an external source, as opposed to resulting from interaction with the component. We might be able to use getA11ySelectionMessage, if we were able to tell the source of the update, but I'm not sure we can.

This was referenced Jun 24, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants