Bug 1624909: Create and maintain radio siblings array for position information. r=eeejay
☠☠ backed out by 8cb2c0a4d3f2 ☠ ☠
authorMorgan Reschenberg <mreschenberg@mozilla.com>
Thu, 28 May 2020 15:53:01 +0000
changeset 532884 d2b10235d8ac07f511a0f8dea35f47c238139c1c
parent 532883 5705e6317112e26f41313fe441093505af12fce6
child 532885 70ad78352bf7fa4454986f2389cf99c74676173f
push id117434
push usermreschenberg@mozilla.com
push dateFri, 29 May 2020 03:50:25 +0000
treeherderautoland@d2b10235d8ac [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerseeejay
bugs1624909
milestone78.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1624909: Create and maintain radio siblings array for position information. r=eeejay Differential Revision: https://phabricator.services.mozilla.com/D72751
accessible/base/Pivot.cpp
accessible/base/Pivot.h
accessible/generic/Accessible.cpp
accessible/html/HTMLFormControlAccessible.cpp
accessible/html/HTMLFormControlAccessible.h
accessible/mac/AccessibleWrap.mm
accessible/mac/mozActionElements.h
accessible/mac/mozActionElements.mm
accessible/tests/browser/mac/browser.ini
accessible/tests/browser/mac/browser_radio_position.js
--- a/accessible/base/Pivot.cpp
+++ b/accessible/base/Pivot.cpp
@@ -528,8 +528,26 @@ Accessible* Pivot::AtPoint(int32_t aX, i
       }
     }
 
     child = child->Parent();
   }
 
   return match;
 }
+
+// Role Rule
+
+PivotRoleRule::PivotRoleRule(mozilla::a11y::role aRole) : mRole(aRole) {}
+
+uint16_t PivotRoleRule::Match(Accessible* aAccessible) {
+  uint16_t result = nsIAccessibleTraversalRule::FILTER_IGNORE;
+
+  if (nsAccUtils::MustPrune(aAccessible)) {
+    result |= nsIAccessibleTraversalRule::FILTER_IGNORE_SUBTREE;
+  }
+
+  if (aAccessible->Role() == mRole) {
+    result |= nsIAccessibleTraversalRule::FILTER_MATCH;
+  }
+
+  return result;
+}
--- a/accessible/base/Pivot.h
+++ b/accessible/base/Pivot.h
@@ -78,12 +78,25 @@ class Pivot final {
                              bool aSearchCurrent);
 
   // Search in preorder for the first text accessible.
   HyperTextAccessible* SearchForText(Accessible* aAnchor, bool aBackward);
 
   Accessible* mRoot;
 };
 
+/**
+ * This rule matches accessibles on a given role.
+ */
+class PivotRoleRule final : public PivotRule {
+ public:
+  explicit PivotRoleRule(role aRole);
+
+  virtual uint16_t Match(Accessible* aAccessible) override;
+
+ private:
+  role mRole;
+};
+
 }  // namespace a11y
 }  // namespace mozilla
 
 #endif  // mozilla_a11y_Pivot_h_
--- a/accessible/generic/Accessible.cpp
+++ b/accessible/generic/Accessible.cpp
@@ -14,16 +14,17 @@
 #include "nsAccessiblePivot.h"
 #include "nsGenericHTMLElement.h"
 #include "NotificationController.h"
 #include "nsEventShell.h"
 #include "nsTextEquivUtils.h"
 #include "DocAccessibleChild.h"
 #include "EventTree.h"
 #include "GeckoProfiler.h"
+#include "Pivot.h"
 #include "Relation.h"
 #include "Role.h"
 #include "RootAccessible.h"
 #include "States.h"
 #include "StyleInfo.h"
 #include "TableAccessible.h"
 #include "TableCellAccessible.h"
 #include "TreeWalker.h"
@@ -1677,18 +1678,49 @@ Relation Accessible::RelationByType(Rela
     case RelationType::FLOWS_TO:
       return Relation(
           new IDRefsIterator(mDoc, mContent, nsGkAtoms::aria_flowto));
 
     case RelationType::FLOWS_FROM:
       return Relation(
           new RelatedAccIterator(Document(), mContent, nsGkAtoms::aria_flowto));
 
-    case RelationType::MEMBER_OF:
+    case RelationType::MEMBER_OF: {
+      if (Role() == roles::RADIOBUTTON) {
+        /* If we see a radio button role here, we're dealing with an aria
+         * radio button (because input=radio buttons are
+         * HTMLRadioButtonAccessibles) */
+        Relation rel = Relation();
+        Accessible* currParent = Parent();
+        while (currParent && currParent->Role() != roles::RADIO_GROUP) {
+          currParent = currParent->Parent();
+        }
+
+        if (currParent && currParent->Role() == roles::RADIO_GROUP) {
+          /* If we found a radiogroup parent, search for all
+           * roles::RADIOBUTTON children and add them to our relation.
+           * This search will include the radio button this method
+           * was called from, which is expected. */
+          Pivot p = Pivot(currParent);
+          PivotRoleRule rule(roles::RADIOBUTTON);
+          Accessible* match = currParent;
+          while ((match = p.Next(match, rule))) {
+            rel.AppendTarget(match);
+          }
+        }
+
+        /* By webkit's standard, aria radio buttons do not get grouped
+         * if they lack a group parent, so we return an empty
+         * relation here if the above check fails. */
+
+        return rel;
+      }
+
       return Relation(mDoc, GetAtomicRegion());
+    }
 
     case RelationType::SUBWINDOW_OF:
     case RelationType::EMBEDS:
     case RelationType::EMBEDDED_BY:
     case RelationType::POPUP_FOR:
     case RelationType::PARENT_WINDOW_OF:
       return Relation();
 
--- a/accessible/html/HTMLFormControlAccessible.cpp
+++ b/accessible/html/HTMLFormControlAccessible.cpp
@@ -65,16 +65,22 @@ uint64_t HTMLRadioButtonAccessible::Nati
   HTMLInputElement* input = HTMLInputElement::FromNode(mContent);
   if (input && input->Checked()) state |= states::CHECKED;
 
   return state;
 }
 
 void HTMLRadioButtonAccessible::GetPositionAndSizeInternal(int32_t* aPosInSet,
                                                            int32_t* aSetSize) {
+  Unused << ComputeGroupAttributes(aPosInSet, aSetSize);
+}
+
+Relation HTMLRadioButtonAccessible::ComputeGroupAttributes(
+    int32_t* aPosInSet, int32_t* aSetSize) const {
+  Relation rel = Relation();
   int32_t namespaceId = mContent->NodeInfo()->NamespaceID();
   nsAutoString tagName;
   mContent->NodeInfo()->GetName(tagName);
 
   nsAutoString type;
   mContent->AsElement()->GetAttr(kNameSpaceID_None, nsGkAtoms::type, type);
   nsAutoString name;
   mContent->AsElement()->GetAttr(kNameSpaceID_None, nsGkAtoms::name, name);
@@ -82,38 +88,49 @@ void HTMLRadioButtonAccessible::GetPosit
   RefPtr<nsContentList> inputElms;
 
   nsCOMPtr<nsIFormControl> formControlNode(do_QueryInterface(mContent));
   dom::Element* formElm = formControlNode->GetFormElement();
   if (formElm)
     inputElms = NS_GetContentList(formElm, namespaceId, tagName);
   else
     inputElms = NS_GetContentList(mContent->OwnerDoc(), namespaceId, tagName);
-  NS_ENSURE_TRUE_VOID(inputElms);
+  NS_ENSURE_TRUE(inputElms, rel);
 
   uint32_t inputCount = inputElms->Length(false);
 
   // Compute posinset and setsize.
   int32_t indexOf = 0;
   int32_t count = 0;
 
   for (uint32_t index = 0; index < inputCount; index++) {
     nsIContent* inputElm = inputElms->Item(index, false);
     if (inputElm->AsElement()->AttrValueIs(kNameSpaceID_None, nsGkAtoms::type,
                                            type, eCaseMatters) &&
         inputElm->AsElement()->AttrValueIs(kNameSpaceID_None, nsGkAtoms::name,
                                            name, eCaseMatters) &&
         mDoc->HasAccessible(inputElm)) {
       count++;
+      rel.AppendTarget(mDoc->GetAccessible(inputElm));
       if (inputElm == mContent) indexOf = count;
     }
   }
 
   *aPosInSet = indexOf;
   *aSetSize = count;
+  return rel;
+}
+
+Relation HTMLRadioButtonAccessible::RelationByType(RelationType aType) const {
+  if (aType == RelationType::MEMBER_OF) {
+    int32_t unusedPos, unusedSetSize;
+    return ComputeGroupAttributes(&unusedPos, &unusedSetSize);
+  }
+
+  return Accessible::RelationByType(aType);
 }
 
 ////////////////////////////////////////////////////////////////////////////////
 // HTMLButtonAccessible
 ////////////////////////////////////////////////////////////////////////////////
 
 HTMLButtonAccessible::HTMLButtonAccessible(nsIContent* aContent,
                                            DocAccessible* aDoc)
--- a/accessible/html/HTMLFormControlAccessible.h
+++ b/accessible/html/HTMLFormControlAccessible.h
@@ -4,16 +4,17 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 #ifndef MOZILLA_A11Y_HTMLFormControlAccessible_H_
 #define MOZILLA_A11Y_HTMLFormControlAccessible_H_
 
 #include "FormControlAccessible.h"
 #include "HyperTextAccessibleWrap.h"
 #include "nsAccUtils.h"
+#include "Relation.h"
 
 namespace mozilla {
 class TextEditor;
 namespace a11y {
 
 /**
  * Accessible for HTML input@type="radio" element.
  */
@@ -25,16 +26,20 @@ class HTMLRadioButtonAccessible : public
     // state change notification.
     mStateFlags |= eIgnoreDOMUIEvent;
   }
 
   // Accessible
   virtual uint64_t NativeState() const override;
   virtual void GetPositionAndSizeInternal(int32_t* aPosInSet,
                                           int32_t* aSetSize) override;
+  virtual Relation RelationByType(RelationType aType) const override;
+
+ private:
+  Relation ComputeGroupAttributes(int32_t* aPosInSet, int32_t* aSetSize) const;
 };
 
 /**
  * Accessible for HTML input@type="button", @type="submit", @type="image"
  * and HTML button elements.
  */
 class HTMLButtonAccessible : public HyperTextAccessibleWrap {
  public:
--- a/accessible/mac/AccessibleWrap.mm
+++ b/accessible/mac/AccessibleWrap.mm
@@ -176,18 +176,20 @@ Class a11y::GetTypeFromRole(roles::Role 
     case roles::PUSHBUTTON:
       return [mozButtonAccessible class];
 
     case roles::PAGETAB:
       return [mozTabAccessible class];
 
     case roles::CHECKBUTTON:
     case roles::TOGGLE_BUTTON:
+      return [mozCheckboxAccessible class];
+
     case roles::RADIOBUTTON:
-      return [mozCheckboxAccessible class];
+      return [mozRadioButtonAccessible class];
 
     case roles::SPINBUTTON:
     case roles::SLIDER:
       return [mozIncrementableAccessible class];
 
     case roles::HEADING:
       return [mozHeadingAccessible class];
 
--- a/accessible/mac/mozActionElements.h
+++ b/accessible/mac/mozActionElements.h
@@ -16,16 +16,21 @@
 @interface mozPopupButtonAccessible : mozButtonAccessible
 @end
 
 @interface mozCheckboxAccessible : mozButtonAccessible
 // returns one of the constants defined in CheckboxValue
 - (int)isChecked;
 @end
 
+// Accessible for a radio button
+@interface mozRadioButtonAccessible : mozCheckboxAccessible
+- (id)accessibilityAttributeValue:(NSString*)attribute;
+@end
+
 /**
  * Accessible for a PANE
  */
 @interface mozPaneAccessible : mozAccessible
 
 @end
 
 /**
--- a/accessible/mac/mozActionElements.mm
+++ b/accessible/mac/mozActionElements.mm
@@ -4,16 +4,17 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 #import "mozActionElements.h"
 
 #import "MacUtils.h"
 #include "Accessible-inl.h"
 #include "DocAccessible.h"
 #include "XULTabAccessible.h"
+#include "HTMLFormControlAccessible.h"
 
 #include "nsDeckFrame.h"
 #include "nsObjCExceptions.h"
 
 using namespace mozilla::a11y;
 
 enum CheckboxValue {
   // these constants correspond to the values in the OS
@@ -149,16 +150,48 @@ enum CheckboxValue {
     }
   }
 
   return [super ignoreWithParent:parent];
 }
 
 @end
 
+@implementation mozRadioButtonAccessible
+
+- (id)accessibilityAttributeValue:(NSString*)attribute {
+  NS_OBJC_BEGIN_TRY_ABORT_BLOCK_NIL;
+  if ([self isExpired]) {
+    return nil;
+  }
+
+  if ([attribute isEqualToString:NSAccessibilityLinkedUIElementsAttribute]) {
+    if (HTMLRadioButtonAccessible* radioAcc =
+            (HTMLRadioButtonAccessible*)mGeckoAccessible.AsAccessible()) {
+      NSMutableArray* radioSiblings = [NSMutableArray new];
+      Relation rel = radioAcc->RelationByType(RelationType::MEMBER_OF);
+      Accessible* tempAcc;
+      while ((tempAcc = rel.Next())) {
+        [radioSiblings addObject:GetNativeFromGeckoAccessible(tempAcc)];
+      }
+      return radioSiblings;
+    } else {
+      ProxyAccessible* proxy = mGeckoAccessible.AsProxy();
+      nsTArray<ProxyAccessible*> accs = proxy->RelationByType(RelationType::MEMBER_OF);
+      return utils::ConvertToNSArray(accs);
+    }
+  }
+
+  return [super accessibilityAttributeValue:attribute];
+
+  NS_OBJC_END_TRY_ABORT_BLOCK_NIL;
+}
+
+@end
+
 @implementation mozCheckboxAccessible
 
 - (int)isChecked {
   // check if we're checked or in a mixed state
   uint64_t state = [self stateWithMask:(states::CHECKED | states::PRESSED | states::MIXED)];
   if (state & (states::CHECKED | states::PRESSED)) {
     return kChecked;
   }
--- a/accessible/tests/browser/mac/browser.ini
+++ b/accessible/tests/browser/mac/browser.ini
@@ -12,14 +12,15 @@ support-files =
 [browser_app.js]
 [browser_aria_current.js]
 [browser_details_summary.js]
 [browser_label_title.js]
 [browser_range.js]
 [browser_roles_elements.js]
 [browser_table.js]
 [browser_selectables.js]
+[browser_radio_position.js]
 [browser_toggle_radio_check.js]
 [browser_link.js]
 [browser_aria_haspopup.js]
 [browser_required.js]
 [browser_popupbutton.js]
 [browser_mathml.js]
new file mode 100644
--- /dev/null
+++ b/accessible/tests/browser/mac/browser_radio_position.js
@@ -0,0 +1,321 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/* import-globals-from ../../mochitest/role.js */
+/* import-globals-from ../../mochitest/states.js */
+loadScripts(
+  { name: "role.js", dir: MOCHITESTS_DIR },
+  { name: "states.js", dir: MOCHITESTS_DIR }
+);
+
+function getChildRoles(parent) {
+  return parent
+    .getAttributeValue("AXChildren")
+    .map(c => c.getAttributeValue("AXRole"));
+}
+
+function getLinkedTitles(element) {
+  return element
+    .getAttributeValue("AXLinkedUIElements")
+    .map(c => c.getAttributeValue("AXTitle"));
+}
+
+/**
+ * Test radio group
+ */
+addAccessibleTask(
+  `<div role="radiogroup" id="radioGroup">
+    <div role="radio"
+         id="radioGroupItem1">
+       Regular crust
+    </div>
+    <div role="radio"
+         id="radioGroupItem2">
+       Deep dish
+    </div>
+    <div role="radio"
+         id="radioGroupItem3">
+       Thin crust
+    </div>
+  </div>`,
+  async (browser, accDoc) => {
+    let item1 = getNativeInterface(accDoc, "radioGroupItem1");
+    let item2 = getNativeInterface(accDoc, "radioGroupItem2");
+    let item3 = getNativeInterface(accDoc, "radioGroupItem3");
+    let titleList = ["Regular crust", "Deep dish", "Thin crust"];
+
+    Assert.deepEqual(
+      titleList,
+      [item1, item2, item3].map(c => c.getAttributeValue("AXTitle")),
+      "Title list matches"
+    );
+
+    let linkedElems = item1.getAttributeValue("AXLinkedUIElements");
+    is(linkedElems.length, 3, "Item 1 has three linked UI elems");
+    Assert.deepEqual(
+      getLinkedTitles(item1),
+      titleList,
+      "Item one has correctly ordered linked elements"
+    );
+
+    linkedElems = item2.getAttributeValue("AXLinkedUIElements");
+    is(linkedElems.length, 3, "Item 2 has three linked UI elems");
+    Assert.deepEqual(
+      getLinkedTitles(item2),
+      titleList,
+      "Item two has correctly ordered linked elements"
+    );
+
+    linkedElems = item3.getAttributeValue("AXLinkedUIElements");
+    is(linkedElems.length, 3, "Item 3 has three linked UI elems");
+    Assert.deepEqual(
+      getLinkedTitles(item3),
+      titleList,
+      "Item three has correctly ordered linked elements"
+    );
+  }
+);
+
+/**
+ * Test dynamic add to a radio group
+ */
+addAccessibleTask(
+  `<div role="radiogroup" id="radioGroup">
+    <div role="radio"
+         id="radioGroupItem1">
+       Option One
+    </div>
+  </div>`,
+  async (browser, accDoc) => {
+    let item1 = getNativeInterface(accDoc, "radioGroupItem1");
+    let linkedElems = item1.getAttributeValue("AXLinkedUIElements");
+
+    is(linkedElems.length, 1, "Item 1 has one linked UI elem");
+    is(
+      linkedElems[0].getAttributeValue("AXTitle"),
+      item1.getAttributeValue("AXTitle"),
+      "Item 1 is first element"
+    );
+
+    let reorder = waitForEvent(EVENT_REORDER, "radioGroup");
+    await SpecialPowers.spawn(browser, [], () => {
+      let d = content.document.createElement("div");
+      d.setAttribute("role", "radio");
+      content.document.getElementById("radioGroup").appendChild(d);
+    });
+    await reorder;
+
+    let radioGroup = getNativeInterface(accDoc, "radioGroup");
+    let groupMembers = radioGroup.getAttributeValue("AXChildren");
+    is(groupMembers.length, 2, "Radio group has two members");
+    let item2 = groupMembers[1];
+    item1 = getNativeInterface(accDoc, "radioGroupItem1");
+    let titleList = ["Option One", ""];
+
+    Assert.deepEqual(
+      titleList,
+      [item1, item2].map(c => c.getAttributeValue("AXTitle")),
+      "Title list matches"
+    );
+
+    linkedElems = item1.getAttributeValue("AXLinkedUIElements");
+    is(linkedElems.length, 2, "Item 1 has two linked UI elems");
+    Assert.deepEqual(
+      getLinkedTitles(item1),
+      titleList,
+      "Item one has correctly ordered linked elements"
+    );
+
+    linkedElems = item2.getAttributeValue("AXLinkedUIElements");
+    is(linkedElems.length, 2, "Item 2 has two linked UI elems");
+    Assert.deepEqual(
+      getLinkedTitles(item2),
+      titleList,
+      "Item two has correctly ordered linked elements"
+    );
+  }
+);
+
+/**
+ * Test input[type=radio] for single group
+ */
+addAccessibleTask(
+  `<input type="radio" id="cat" name="animal"><label for="cat">Cat</label>
+   <input type="radio" id="dog" name="animal"><label for="dog">Dog</label>
+   <input type="radio" id="catdog" name="animal"><label for="catdog">CatDog</label>`,
+  async (browser, accDoc) => {
+    let cat = getNativeInterface(accDoc, "cat");
+    let dog = getNativeInterface(accDoc, "dog");
+    let catdog = getNativeInterface(accDoc, "catdog");
+    let titleList = ["Cat", "Dog", "CatDog"];
+
+    Assert.deepEqual(
+      titleList,
+      [cat, dog, catdog].map(x => x.getAttributeValue("AXTitle")),
+      "Title list matches"
+    );
+
+    let linkedElems = cat.getAttributeValue("AXLinkedUIElements");
+    is(linkedElems.length, 3, "Cat has three linked UI elems");
+    Assert.deepEqual(
+      getLinkedTitles(cat),
+      titleList,
+      "Cat has correctly ordered linked elements"
+    );
+
+    linkedElems = dog.getAttributeValue("AXLinkedUIElements");
+    is(linkedElems.length, 3, "Dog has three linked UI elems");
+    Assert.deepEqual(
+      getLinkedTitles(dog),
+      titleList,
+      "Dog has correctly ordered linked elements"
+    );
+
+    linkedElems = catdog.getAttributeValue("AXLinkedUIElements");
+    is(linkedElems.length, 3, "Catdog has three linked UI elems");
+    Assert.deepEqual(
+      getLinkedTitles(catdog),
+      titleList,
+      "catdog has correctly ordered linked elements"
+    );
+  }
+);
+
+/**
+ * Test input[type=radio] for different groups
+ */
+addAccessibleTask(
+  `<input type="radio" id="cat" name="one"><label for="cat">Cat</label>
+   <input type="radio" id="dog" name="two"><label for="dog">Dog</label>
+   <input type="radio" id="catdog"><label for="catdog">CatDog</label>`,
+  async (browser, accDoc) => {
+    let cat = getNativeInterface(accDoc, "cat");
+    let dog = getNativeInterface(accDoc, "dog");
+    let catdog = getNativeInterface(accDoc, "catdog");
+
+    let linkedElems = cat.getAttributeValue("AXLinkedUIElements");
+    is(linkedElems.length, 1, "Cat has one linked UI elem");
+    is(
+      linkedElems[0].getAttributeValue("AXTitle"),
+      cat.getAttributeValue("AXTitle"),
+      "Cat is only element"
+    );
+
+    linkedElems = dog.getAttributeValue("AXLinkedUIElements");
+    is(linkedElems.length, 1, "Dog has one linked UI elem");
+    is(
+      linkedElems[0].getAttributeValue("AXTitle"),
+      dog.getAttributeValue("AXTitle"),
+      "Dog is only element"
+    );
+
+    linkedElems = catdog.getAttributeValue("AXLinkedUIElements");
+    is(linkedElems.length, 0, "Catdog has no linked UI elem");
+  }
+);
+
+/**
+ * Test input[type=radio] for single group across DOM
+ */
+addAccessibleTask(
+  `<input type="radio" id="cat" name="animal"><label for="cat">Cat</label>
+   <div>
+    <span>
+      <input type="radio" id="dog" name="animal"><label for="dog">Dog</label>
+    </span>
+   </div>
+   <div>
+   <input type="radio" id="catdog" name="animal"><label for="catdog">CatDog</label>
+   </div>`,
+  async (browser, accDoc) => {
+    let cat = getNativeInterface(accDoc, "cat");
+    let dog = getNativeInterface(accDoc, "dog");
+    let catdog = getNativeInterface(accDoc, "catdog");
+    let titleList = ["Cat", "Dog", "CatDog"];
+
+    Assert.deepEqual(
+      titleList,
+      [cat, dog, catdog].map(x => x.getAttributeValue("AXTitle")),
+      "Title list matches"
+    );
+
+    let linkedElems = cat.getAttributeValue("AXLinkedUIElements");
+    is(linkedElems.length, 3, "Cat has three linked UI elems");
+    Assert.deepEqual(
+      getLinkedTitles(cat),
+      titleList,
+      "cat has correctly ordered linked elements"
+    );
+
+    linkedElems = dog.getAttributeValue("AXLinkedUIElements");
+    is(linkedElems.length, 3, "Dog has three linked UI elems");
+    Assert.deepEqual(
+      getLinkedTitles(dog),
+      titleList,
+      "dog has correctly ordered linked elements"
+    );
+
+    linkedElems = catdog.getAttributeValue("AXLinkedUIElements");
+    is(linkedElems.length, 3, "Catdog has three linked UI elems");
+    Assert.deepEqual(
+      getLinkedTitles(catdog),
+      titleList,
+      "catdog has correctly ordered linked elements"
+    );
+  }
+);
+
+/**
+ * Test dynamic add of input[type=radio] in a single group
+ */
+addAccessibleTask(
+  `<div id="container"><input type="radio" id="cat" name="animal"></div>`,
+  async (browser, accDoc) => {
+    let cat = getNativeInterface(accDoc, "cat");
+    let container = getNativeInterface(accDoc, "container");
+
+    let containerChildren = container.getAttributeValue("AXChildren");
+    is(containerChildren.length, 1, "container has one button");
+    is(
+      containerChildren[0].getAttributeValue("AXRole"),
+      "AXRadioButton",
+      "Container child is radio button"
+    );
+
+    let linkedElems = cat.getAttributeValue("AXLinkedUIElements");
+    is(linkedElems.length, 1, "Cat has 1 linked UI elem");
+    is(
+      linkedElems[0].getAttributeValue("AXTitle"),
+      cat.getAttributeValue("AXTitle"),
+      "Cat is first element"
+    );
+    let reorder = waitForEvent(EVENT_REORDER, "container");
+    await SpecialPowers.spawn(browser, [], () => {
+      let input = content.document.createElement("input");
+      input.setAttribute("type", "radio");
+      input.setAttribute("name", "animal");
+      content.document.getElementById("container").appendChild(input);
+    });
+    await reorder;
+
+    container = getNativeInterface(accDoc, "container");
+    containerChildren = container.getAttributeValue("AXChildren");
+
+    is(containerChildren.length, 2, "container has two children");
+
+    Assert.deepEqual(
+      getChildRoles(container),
+      ["AXRadioButton", "AXRadioButton"],
+      "Both children are radio buttons"
+    );
+
+    linkedElems = containerChildren[0].getAttributeValue("AXLinkedUIElements");
+    is(linkedElems.length, 2, "Cat has 2 linked elements");
+
+    linkedElems = containerChildren[1].getAttributeValue("AXLinkedUIElements");
+    is(linkedElems.length, 2, "New button has 2 linked elements");
+  }
+);