Bug 224582 - AX: `aria-activedescendant` doesn't work with a standard "listbox" pattern
Summary: AX: `aria-activedescendant` doesn't work with a standard "listbox" pattern
Status: RESOLVED FIXED
Alias: None
Product: WebKit
Classification: Unclassified
Component: Accessibility (show other bugs)
Version: Safari 14
Hardware: All All
: P2 Normal
Assignee: chris fleizach
URL:
Keywords: InRadar
Depends on:
Blocks:
 
Reported: 2021-04-14 15:57 PDT by Ben Cronin
Modified: 2023-01-04 23:09 PST (History)
12 users (show)

See Also:


Attachments
Patch (10.96 KB, patch)
2022-12-25 00:08 PST, chris fleizach
no flags Details | Formatted Diff | Diff
Patch (11.30 KB, patch)
2022-12-25 14:27 PST, chris fleizach
no flags Details | Formatted Diff | Diff
Patch (11.43 KB, patch)
2022-12-27 00:06 PST, chris fleizach
no flags Details | Formatted Diff | Diff
Patch (11.37 KB, patch)
2023-01-04 08:45 PST, chris fleizach
no flags Details | Formatted Diff | Diff
Patch (11.37 KB, patch)
2023-01-04 09:20 PST, chris fleizach
no flags Details | Formatted Diff | Diff

Note You need to log in before you can comment on or make changes to this bug.
Description Ben Cronin 2021-04-14 15:57:41 PDT
With VoiceOver running, go to the example at https://jsfiddle.net/upy3m6n7/3/ and place the cursor inside the "Editable text" field. Use the arrow keys to move through the options (the selected option will get a "selected" class and become `aria-activedescendant` of the "editor" textbox element).

Expected: VoiceOver will identify each option as it is selected and becomes the `aria-activedescendant` of the "editor" element (this happens in Chrome and Firefox).

Observed: VoiceOver reads out nothing as each option becomes selected; there is no indication that there is an active descendant. 

The spec clearly indicates that elements with "textbox" role and correct markup should roles should accommodate `aria-activedescendant` (see https://www.w3.org/TR/wai-aria/#aria-activedescendant, particularly item 2). FWIW, though, as far as I can tell, the above behavior occurs with any role applied to the "editor" node.
Comment 1 Radar WebKit Bug Importer 2021-04-14 15:57:50 PDT
<rdar://problem/76670072>
Comment 2 Dhawal 2022-04-12 02:25:47 PDT
Same issue here. Here's our implementation https://jsfiddle.net/f5Larbnk/19/

Click on button and hover on the options. Note that voiceover in the above fiddle works fine in Firefox, Chrome when active-descendant is updated.
Comment 3 chris fleizach 2022-12-25 00:08:15 PST
Created attachment 464203 [details]
Patch
Comment 4 Tyler Wilcock 2022-12-25 10:55:12 PST
Comment on attachment 464203 [details]
Patch

View in context: https://bugs.webkit.org/attachment.cgi?id=464203&action=review

> Source/WebCore/accessibility/AXObjectCache.cpp:1898
> +    if (!element.document().frame()->selection().isFocusedAndActive())

Do you think it would be useful to have a comment stating why it's important to use the element document and not the cache document? Perhaps that will help prevent us from making the same mistake again.

> Source/WebCore/accessibility/AXObjectCache.cpp:1932
> +        target = Accessibility::findAncestor(*activeDescendant, true, [&object] (const auto& ancestor) {
> +            return object->relatedObjects(AXRelationType::ControllerFor).contains(&ancestor);
> +        });

Could you explain what the intention of this loop is? It's confusing to me because:

  1. In the first iteration of the loop, `const auto& ancestor` is actually not an ancestor, it's a descendant (*activeDescendant)
  2. Because we start iterating from a descendant, eventually we will iterate to `object`, and then above `object`, checking to see if it aria-controls itself, and then any of its ancestors. Does this make sense?

I think this code works for the simple listbox example in this bug because we return true in the first iteration. But I don't understand what this loop accomplishes generally, which might just be my lack of understanding. Can you explain what the intention is here?

> Source/WebCore/accessibility/AXObjectCache.cpp:4104
> +        if (is<HTMLFrameOwnerElement>(element))
> +            updateRelationsForTree(*downcast<HTMLFrameOwnerElement>(element).contentDocument());

Nit: this could arguably be simpler with dynamicDowncast.

if (auto* frameOwnerElement = dynamicDowncast<HTMLFrameOwnerElement>(element))
    updateRelationsForTree(frameOwnerElement->contentDocument());

> LayoutTests/accessibility/mac/relationships-in-frames.html:35
> +	    debug(output);

The indentation level of this debug doesn't match its siblings.
Comment 5 chris fleizach 2022-12-25 14:24:05 PST
(In reply to Tyler Wilcock from comment #4)
> Comment on attachment 464203 [details]
> Patch
> 
> View in context:
> https://bugs.webkit.org/attachment.cgi?id=464203&action=review
> 
> > Source/WebCore/accessibility/AXObjectCache.cpp:1898
> > +    if (!element.document().frame()->selection().isFocusedAndActive())
> 
> Do you think it would be useful to have a comment stating why it's important
> to use the element document and not the cache document? Perhaps that will
> help prevent us from making the same mistake again.
> 
> > Source/WebCore/accessibility/AXObjectCache.cpp:1932
> > +        target = Accessibility::findAncestor(*activeDescendant, true, [&object] (const auto& ancestor) {
> > +            return object->relatedObjects(AXRelationType::ControllerFor).contains(&ancestor);
> > +        });
> 
> Could you explain what the intention of this loop is? It's confusing to me
> because:
> 
>   1. In the first iteration of the loop, `const auto& ancestor` is actually
> not an ancestor, it's a descendant (*activeDescendant)
>   2. Because we start iterating from a descendant, eventually we will
> iterate to `object`, and then above `object`, checking to see if it
> aria-controls itself, and then any of its ancestors. Does this make sense?
> 
> I think this code works for the simple listbox example in this bug because
> we return true in the first iteration. But I don't understand what this loop
> accomplishes generally, which might just be my lack of understanding. Can
> you explain what the intention is here?
> 
Added better comments in the code.
Basically, the target needs to be thing we send out the notification for. The active descendant needs to be a descendant of that, but the one generating the notification is the controller.

So we go up the active-descendant hierarchy to see if we find the object that is being controlled by the "controller". If we find that object than we use it as the target.

> > Source/WebCore/accessibility/AXObjectCache.cpp:4104
> > +        if (is<HTMLFrameOwnerElement>(element))
> > +            updateRelationsForTree(*downcast<HTMLFrameOwnerElement>(element).contentDocument());
> 
> Nit: this could arguably be simpler with dynamicDowncast.
> 
> if (auto* frameOwnerElement =
> dynamicDowncast<HTMLFrameOwnerElement>(element))
>     updateRelationsForTree(frameOwnerElement->contentDocument());
> 
> > LayoutTests/accessibility/mac/relationships-in-frames.html:35
> > +	    debug(output);
> 
> The indentation level of this debug doesn't match its siblings.

Will make these changes
Comment 6 chris fleizach 2022-12-25 14:27:02 PST
Created attachment 464211 [details]
Patch
Comment 7 Tyler Wilcock 2022-12-25 14:32:43 PST
Comment on attachment 464211 [details]
Patch

View in context: https://bugs.webkit.org/attachment.cgi?id=464211&action=review

> Source/WebCore/accessibility/AXObjectCache.cpp:1935
> +        target = Accessibility::findAncestor(*activeDescendant, false, [&object] (const auto& activeDescendantAncestor) {
> +            return object->relatedObjects(AXRelationType::ControllerFor).contains(&activeDescendantAncestor);
> +        });

Thanks for clarifying. One last question: rather than re-computing object->relatedObjects(AXRelationType::ControllerFor) every iteration, can we extract it into a local variable?
Comment 8 chris fleizach 2022-12-27 00:06:09 PST
Created attachment 464221 [details]
Patch
Comment 9 Andres Gonzalez 2023-01-04 05:12:02 PST
(In reply to chris fleizach from comment #8)
> Created attachment 464221 [details]
> Patch

--- /dev/null
+++ b/LayoutTests/accessibility/mac/active-descendant-with-aria-controls.html

+var output = "This test ensures objects that control objects with active descendants generate the correct notifications, while also being inside frames.\n\n";

No iframe involved in this test.

Not sure that this isn't flaky because the order of the calls to debug() in the notificationCallback and main is not deterministic. There may be differences in timing between ITM on and off. I would run it with --iterations=100 to ensure.
Comment 10 chris fleizach 2023-01-04 08:45:59 PST
Created attachment 464329 [details]
Patch
Comment 11 chris fleizach 2023-01-04 09:20:08 PST
Created attachment 464330 [details]
Patch
Comment 12 EWS 2023-01-04 23:09:13 PST
Committed 258478@main (f78fd4847fdb): <https://commits.webkit.org/258478@main>

All reviewed patches have been landed. Closing bug and clearing flags on attachment 464330 [details].