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

[WB-1891]: Integrating Announcer into SingleSelect and MultiSelect #2495

Open
wants to merge 27 commits into
base: feature/announcer
Choose a base branch
from

Conversation

marcysutton
Copy link
Member

Summary:

Integrating WB Announcer into SingleSelect and MultiSelect for updating combobox values. This work is intended to work around VoiceOver/Safari bugs where the value isn't read out properly in an opener.

Issue: WB-1891

Test plan:

  1. Review SingleSelect and MultiSelect stories in Safari and VO
  2. Ensure live region announcements occur first-time through
  3. Ensure announcement messages are relevant to the UX (not stale)

@marcysutton marcysutton self-assigned this Mar 6, 2025
Copy link

changeset-bot bot commented Mar 6, 2025

🦋 Changeset detected

Latest commit: bf3193a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@khanacademy/wonder-blocks-announcer Major
@khanacademy/wonder-blocks-dropdown Minor
@khanacademy/wonder-blocks-birthday-picker Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@khan-actions-bot khan-actions-bot requested a review from a team March 6, 2025 19:55
@khan-actions-bot
Copy link
Contributor

khan-actions-bot commented Mar 6, 2025

Gerald

Required Reviewers
  • @Khan/wonder-blocks for changes to pnpm-lock.yaml, .changeset/clean-peas-prove.md, .changeset/plenty-crews-search.md, __docs__/wonder-blocks-announcer/announcer.stories.tsx, __docs__/wonder-blocks-dropdown/multi-select.accessibility.mdx, __docs__/wonder-blocks-dropdown/multi-select.accessibility.stories.tsx, __docs__/wonder-blocks-dropdown/single-select.accessibility.mdx, __docs__/wonder-blocks-dropdown/single-select.accessibility.stories.tsx, packages/wonder-blocks-announcer/package.json, packages/wonder-blocks-dropdown/package.json, packages/wonder-blocks-dropdown/tsconfig-build.json, packages/wonder-blocks-announcer/src/announce-message.ts, packages/wonder-blocks-announcer/src/announcer.ts, packages/wonder-blocks-announcer/src/index.ts, packages/wonder-blocks-announcer/src/init-announcer.ts, packages/wonder-blocks-announcer/src/__tests__/announce-message.test.tsx, packages/wonder-blocks-announcer/src/__tests__/announcer.test.ts, packages/wonder-blocks-announcer/src/__tests__/clear-messages.test.tsx, packages/wonder-blocks-announcer/src/__tests__/init-announcer.test.tsx, packages/wonder-blocks-announcer/src/util/announcer.types.ts, packages/wonder-blocks-announcer/src/util/dom.ts, packages/wonder-blocks-announcer/src/util/util.ts, packages/wonder-blocks-dropdown/src/components/dropdown-core.tsx, packages/wonder-blocks-dropdown/src/components/multi-select.tsx, packages/wonder-blocks-dropdown/src/components/single-select.tsx, packages/wonder-blocks-dropdown/src/util/helpers.ts, packages/wonder-blocks-announcer/src/__tests__/util/dom.test.ts, packages/wonder-blocks-announcer/src/__tests__/util/test-utilities.ts, packages/wonder-blocks-announcer/src/__tests__/util/util.test.ts, packages/wonder-blocks-birthday-picker/src/components/__tests__/birthday-picker.test.tsx, packages/wonder-blocks-dropdown/src/components/__tests__/dropdown-core.test.tsx, packages/wonder-blocks-dropdown/src/components/__tests__/multi-select.test.tsx, packages/wonder-blocks-dropdown/src/components/__tests__/single-select.test.tsx, packages/wonder-blocks-dropdown/src/util/__tests__/helpers.test.tsx

Don't want to be involved in this pull request? Comment #removeme and we won't notify you of further changes.

Copy link
Contributor

github-actions bot commented Mar 6, 2025

Size Change: +257 B (+0.26%)

Total Size: 101 kB

Filename Size Change
packages/wonder-blocks-announcer/dist/es/index.js 1.95 kB -31 B (-1.56%)
packages/wonder-blocks-dropdown/dist/es/index.js 19.8 kB +288 B (+1.48%)
ℹ️ View Unchanged
Filename Size
packages/wonder-blocks-accordion/dist/es/index.js 3.54 kB
packages/wonder-blocks-banner/dist/es/index.js 1.55 kB
packages/wonder-blocks-birthday-picker/dist/es/index.js 1.88 kB
packages/wonder-blocks-breadcrumbs/dist/es/index.js 886 B
packages/wonder-blocks-button/dist/es/index.js 4.34 kB
packages/wonder-blocks-cell/dist/es/index.js 2.01 kB
packages/wonder-blocks-clickable/dist/es/index.js 3.07 kB
packages/wonder-blocks-core/dist/es/index.js 2.85 kB
packages/wonder-blocks-data/dist/es/index.js 6.25 kB
packages/wonder-blocks-form/dist/es/index.js 6.04 kB
packages/wonder-blocks-grid/dist/es/index.js 1.36 kB
packages/wonder-blocks-icon-button/dist/es/index.js 3.15 kB
packages/wonder-blocks-icon/dist/es/index.js 873 B
packages/wonder-blocks-labeled-field/dist/es/index.js 1.22 kB
packages/wonder-blocks-layout/dist/es/index.js 1.82 kB
packages/wonder-blocks-link/dist/es/index.js 2.04 kB
packages/wonder-blocks-modal/dist/es/index.js 5.5 kB
packages/wonder-blocks-pill/dist/es/index.js 1.49 kB
packages/wonder-blocks-popover/dist/es/index.js 4.92 kB
packages/wonder-blocks-progress-spinner/dist/es/index.js 1.52 kB
packages/wonder-blocks-search-field/dist/es/index.js 1.32 kB
packages/wonder-blocks-switch/dist/es/index.js 2.02 kB
packages/wonder-blocks-testing-core/dist/es/index.js 3.91 kB
packages/wonder-blocks-testing/dist/es/index.js 1.07 kB
packages/wonder-blocks-theming/dist/es/index.js 679 B
packages/wonder-blocks-timing/dist/es/index.js 1.79 kB
packages/wonder-blocks-tokens/dist/es/index.js 2.69 kB
packages/wonder-blocks-toolbar/dist/es/index.js 923 B
packages/wonder-blocks-tooltip/dist/es/index.js 7.01 kB
packages/wonder-blocks-typography/dist/es/index.js 1.23 kB

compressed-size-action

Copy link
Contributor

github-actions bot commented Mar 6, 2025

npm Snapshot: Published

🎉 Good news!! We've packaged up the latest commit from this PR (8504deb) and published all packages with changesets to npm.

You can install the packages in webapp by running:

./services/static/dev/tools/deploy_wonder_blocks.js --tag="PR2495"

Packages can also be installed manually by running:

pnpm add @khanacademy/wonder-blocks-<package-name>@PR2495

Copy link
Contributor

github-actions bot commented Mar 6, 2025

A new build was pushed to Chromatic! 🚀

https://5e1bf4b385e3fb0020b7073c-dqbwvbivds.chromatic.com/

Chromatic results:

Metric Total
Captured snapshots 90
Tests with visual changes 1
Total stories 569
Inherited (not captured) snapshots [TurboSnap] 291
Tests on the build 381

Copy link
Member

@beaesguerra beaesguerra left a comment

Choose a reason for hiding this comment

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

Exciting to see the announcer be used in places! I left some questions I had so far! I'll do some testing as well!

@@ -159,7 +159,7 @@ describe("Announcer.announceMessage", () => {
expect(message1Region).toHaveTextContent(message1);

expect(setTimeout).toHaveBeenNthCalledWith(
1,
2,
Copy link
Member

Choose a reason for hiding this comment

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

Is there a reason why setTimeout is called twice now? Would be helpful to add a comment here so we remember why!

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, I'll add a comment! This was due to the leading edge / trailing edge change in the debounce function. I think the first setTimeout is the initial one for Safari timing, so this configurable one in the test with specific duration/etc. comes second.

@@ -161,6 +161,7 @@ describe("Announcer class", () => {
const waitThreshold = 1000;

// Act
// The second call will win out in the trailing edge implementation
Copy link
Member

Choose a reason for hiding this comment

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

Great job setting up these tests with the initial implementation! Makes it easy to see what's changed with the updated behaviour 😄

Copy link
Member

Choose a reason for hiding this comment

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

Would it be helpful to document the announcer behaviour for SingleSelect and MultiSelect? We have accessibility pages for both!

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, good call!

@@ -316,6 +324,10 @@ const SingleSelect = (props: Props) => {
});
const hasError = error || !!errorMessage;

React.useEffect(() => {
initAnnouncer();
Copy link
Member

Choose a reason for hiding this comment

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

This is great that we get to test out how the announcer is used by other things! I have a few questions 😄

  • Do all of the consumers of the announcer have to initialize the announcer?
  • Would there be any issues if a consumer didn't initialize the announcer? Would there be any issues if the announcer is initialized multiple times? (left a related comment in the initAnnouncer function!)
  • Could we call initAnnouncer when announceMessage is used when the announcer isn't initialized yet? I'm wondering if initAnnouncer should be private to the announcer package so consumers don't have to worry about setting it up!
    • If we need to initialize the announcer even earlier, could we initialize the announcer in a central place so that we don't have to initialize it when we use it? Maybe in something like RenderStateRoot or something similar? I'm not sure the best place to put it! I was thinking of RenderStateRoot because it's high up in the render tree (webapp example)!

Copy link
Member Author

Choose a reason for hiding this comment

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

Good questions! All consumers don't have to initialize the Announcer, as it already initializes itself when you call announceMessage. There were two reasons I added this initAnnouncer option:

  • To make the debounceThreshold (and potentially other options) configurable in tests when you aren't handling the public Announcer functions directly (like in SingleSelect tests, where it's called internally)
  • To improve reliability on the first call in VoiceOver and Safari -- which I should definitely document if we keep it in there! It injects the live regions earlier so that screen readers can pick them up every time.

The initAnnouncer function would have to be public in order for consumers to call it.

Initializing it in a central place on page load also makes a lot of sense to me, assuming it doesn't have a negative impact on performance or something. I can look into initializing it in RenderStateRoot in my webapp integration work! It would always return the same Announcer instance no matter how it's initialized, so we could probably do both.

Copy link
Member

Choose a reason for hiding this comment

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

Sounds good! For context, RenderStateRoot is a WB Core component! We have a storybook decorator to wrap our stories with the RenderStateRoot component. Looking at the RenderStateRoot implementation more, maybe we should have a different component to initialize the announcer so we can separate the concerns! Or we can call initAnnouncer directly when Storybook or the app first mounts!

Copy link
Member Author

Choose a reason for hiding this comment

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

Ooh, thanks for this! I actually might need it for some target node customization I want to do in Storybook. I need to inject the Announcer into a more precise place within the docs examples so it doesn't overlap story content when shown visually. It's also helping me look more closely at the lifecycle of the initAnnouncer function, as it appears to be called much later than I intended. I'll check this out!

export function initAnnouncer(props?: InitAnnouncerProps): Announcer {
const announcer = Announcer.getInstance();
if (props?.debounceThreshold !== undefined) {
announcer.updateWaitThreshold(props?.debounceThreshold);
Copy link
Member

Choose a reason for hiding this comment

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

Since there can only be one instance of the announcer, could it cause issues if different consumers called initAnnouncer with different debounceThreshold values? The last thing to call initAnnouncer would override the previous value

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, that's a good point. It could change on every call.. but I think that is what you would want. Maybe we could play with it more in webapp and see if that becomes an issue?

@@ -1724,9 +1745,15 @@ describe("MultiSelect", () => {
await userEvent.click(textbox);
await userEvent.paste("ear");

// wait to avoid getting caught in the Announcer debounce
jest.advanceTimersByTime(250);
Copy link
Member

Choose a reason for hiding this comment

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

Is 250 a specific number that needs to be used? I see we also advance timers by 10!

I wonder if it would be helpful to add some docs around how to write unit tests for things using the announcer so other teams know how to create tests for it! Things like how to look for the announcer in the DOM, how long to advance timers, and any other internal details that would impact how it gets tested. Or maybe we could provide a testing utility to encapsulate these details? Just a thought!

Copy link
Member Author

@marcysutton marcysutton Mar 7, 2025

Choose a reason for hiding this comment

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

I love that idea! The 250 number is the default debounceThreshold. The 10 number came up when I was using a 0 value in tests... setTimeouts with a value of 0 weren't quite immediate in the JavaScript event loop so I had to advance Jest timers 10! I can write a doc on testing this so the testing path is more well-trodden.

Copy link
Member

Choose a reason for hiding this comment

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

Thinking about this more, do want consumers of the announcer to mock out the announcer instead so they don't have to worry about the implementation details? Their tests could be around making sure the announcer is called with the correct message! Then the announcer's unit tests are what makes sure it's putting the content in the live region correctly. What do you think?

Copy link
Member Author

Choose a reason for hiding this comment

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

That's a great suggestion. I'd love to make the tests easier to work with, and mocking could be a really helpful way to do that!

Copy link
Member

@beaesguerra beaesguerra left a comment

Choose a reason for hiding this comment

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

I left more questions that applies to both single and multiselect! 😄


if (openerStringValue) {
// opener value changed, so let's announce it
handleAnnouncement(openerStringValue);
Copy link
Member

Choose a reason for hiding this comment

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

If I'm understanding correctly, this will announce the message on every render, even when the opener value hasn't changed (for example, every time the dropdown is opened/closed). Would it make sense to only announce it when the opener value changes? We could do that by calling the announcer in a useEffect with selectedValues, or we could call it whenever we trigger the onChange event prop for the MultiSelect! What do you think?

Copy link
Member Author

@marcysutton marcysutton Mar 7, 2025

Choose a reason for hiding this comment

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

I debated on whether to gate this with opener value changes, and ultimately kept it this way because of the bugs in Safari and VoiceOver. It will always push an announcement of this particular value to counteract the buggy clipped and stale value that is announced in VO regardless of the visual state of it!

I also played with setting state for these values and using useEffect and got thrown into an infinite loop. This was the only way I could get it to work with the amount of re-rendering going on.

const menuContent = getMenuTextOrNode(allChildren);
const menuTextOrNode = getMenuTextOrNode(allChildren);
const [openerStringValue, openerContent] =
maybeExtractStringFromNode(menuTextOrNode);
Copy link
Member

Choose a reason for hiding this comment

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

To simplify things, could we use the getLabel utility directly to get the announcement message? It looks like we updated getMenuTextOrNode and added maybeExtractStringFromNode to get the openerStringValue, which looks to always be the return value from getLabel!

Let me know if I'm missing something though! It always takes me longer to understand the menu opener with all the flexibility it supports 😅

Copy link
Member Author

Choose a reason for hiding this comment

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

I wish we could... unless I'm missing something, getLabel returns a node sometimes that is the wrong type for a live region (which requires a string). I needed a string label consistently, so that's why I had to maybeExtractStringFromNode.

Copy link
Member

Choose a reason for hiding this comment

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

getLabel will always return a string 😄

export function getLabel(props: OptionItemProps): string {
if (isString(props.label)) {
return props.label;
}
if (isString(props.labelAsText)) {
return props.labelAsText;
}
return "";
}

getSelectOpenerLabel returns a string or node!

Copy link
Member Author

@marcysutton marcysutton Mar 7, 2025

Choose a reason for hiding this comment

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

Oh yes, you're right! Drowning in complexity a bit here... I'll test it out! Simplification is always a good idea when we can do it. That empty string case at the end might be an issue, but I think it was also foundational in my more complicated logic anyway!

Copy link
Member Author

@marcysutton marcysutton Mar 7, 2025

Choose a reason for hiding this comment

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

Ah, I remember now... it's the complexity from getMenuTextOrNode in MultiSelect that made this difficult, as there are multiple cases for selected items in a switch statement that return a string OR a node. I wanted to leverage that logic without having to iterate through it all again!

Copy link
Member

Choose a reason for hiding this comment

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

Ahh gotcha, that makes sense!

@@ -607,6 +631,10 @@ const MultiSelect = (props: Props) => {
const filteredItems = getMenuItems(allChildren);
const isDisabled = numEnabledOptions === 0 || disabled;

if (open && isFilterable) {
handleAnnouncement(labels.someSelected(filteredItems.length));
Copy link
Member

Choose a reason for hiding this comment

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

Would it make sense to trigger this announcement when an event occurs or when the data changes?

Copy link
Member Author

Choose a reason for hiding this comment

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

I looked into that too...this code was the biggest culprit in triggering infinite loops as it's in the main component thread. The filtered items aren't tracked with state, so I'd have to find a safe way to do that. The change event for this doesn't do any filtering on children. I could try moving it back into dropdown-core, but I hoped to keep all announcement handling centralized in SingleSelect and MultiSelect, respectively. I'm open to suggestions as this was really tricky!

Copy link
Member

Choose a reason for hiding this comment

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

Ahh I see, this is what you were mentioning the other day! I'll poke around and get back to you 😄

Copy link
Member

@beaesguerra beaesguerra Mar 7, 2025

Choose a reason for hiding this comment

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

Tinkered around a bit in SingleSelect and was able to reduce the announcement to only when the selectedValue/children changes and when the filtered items length changes! https://github.com/Khan/wonder-blocks/compare/combobox-announcer-v2...combobox-tinkering?expand=1 Let me know what you think :)

(Note: I duplicated some logic to figure out the selectedOption within the useEffect to avoid additional renders. We could memoize that logic since other things use that logic too!)

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks so much for this Bea!! I appreciate you taking the time to tinker with it. I'm testing it out in Safari and VO and it is working so far... I'll keep playing with it and a MultiSelect version.

@khan-actions-bot khan-actions-bot requested a review from a team March 8, 2025 01:08
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.

3 participants