Skip to content

Commit 4ddf815

Browse files
chore(treeview): add treeitem role to shadow dom node (#3894)
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
1 parent d53a236 commit 4ddf815

3 files changed

Lines changed: 59 additions & 0 deletions

File tree

.changeset/nasty-moles-sip.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/view-components": patch
3+
---
4+
5+
chore(treeview): add treeitem role to shadow dom node

app/components/primer/alpha/tree_view/tree_view_sub_tree_node_element.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,12 @@ export class TreeViewSubTreeNodeElement extends HTMLElement {
178178
// sub-tree and no node in the entire tree can be focused
179179
const previousNode = this.subTree.querySelector("[tabindex='0']")
180180
previousNode?.setAttribute('tabindex', '-1')
181+
182+
// Also check if the subtree element itself is an include-fragment with role="treeitem" and has focus
183+
if (this.#isIncludeFragment() && this.subTree.getAttribute('tabindex') === '0') {
184+
this.subTree.setAttribute('tabindex', '-1')
185+
}
186+
181187
this.node.setAttribute('tabindex', '0')
182188

183189
this.treeView.dispatchEvent(
@@ -263,6 +269,10 @@ export class TreeViewSubTreeNodeElement extends HTMLElement {
263269
// request succeeded but element has not yet been replaced
264270
case 'include-fragment-replace':
265271
this.#activeElementIsLoader = document.activeElement === this.loadingIndicator.closest('[role=treeitem]')
272+
// Also check if the include-fragment itself has focus (when it has role="treeitem")
273+
if (!this.#activeElementIsLoader && document.activeElement === this.subTree && this.#isIncludeFragment()) {
274+
this.#activeElementIsLoader = true
275+
}
266276
this.loadingState = 'success'
267277
break
268278

@@ -410,6 +420,13 @@ export class TreeViewSubTreeNodeElement extends HTMLElement {
410420
#update() {
411421
if (this.expanded) {
412422
if (this.subTree) this.subTree.hidden = false
423+
if (this.#isIncludeFragment()) {
424+
this.subTree.setAttribute('role', 'treeitem')
425+
// Ensure the include-fragment can participate in roving tab index
426+
if (!this.subTree.hasAttribute('tabindex')) {
427+
this.subTree.setAttribute('tabindex', '-1')
428+
}
429+
}
413430
this.node.setAttribute('aria-expanded', 'true')
414431
this.treeView?.expandAncestorsForNode(this)
415432

@@ -423,6 +440,11 @@ export class TreeViewSubTreeNodeElement extends HTMLElement {
423440
}
424441
} else {
425442
if (this.subTree) this.subTree.hidden = true
443+
if (this.#isIncludeFragment()) {
444+
this.subTree.removeAttribute('role')
445+
// Remove tabindex when role is removed
446+
this.subTree.removeAttribute('tabindex')
447+
}
426448
this.node.setAttribute('aria-expanded', 'false')
427449

428450
if (this.iconPair) {
@@ -453,6 +475,10 @@ export class TreeViewSubTreeNodeElement extends HTMLElement {
453475
}
454476
}
455477

478+
#isIncludeFragment(): boolean {
479+
return this.subTree?.getAttribute('data-target')?.includes('tree-view-sub-tree-node.includeFragment') ?? false
480+
}
481+
456482
get #checkboxElement(): HTMLElement | null {
457483
return this.querySelector('.TreeViewItemCheckbox')
458484
}

test/system/alpha/tree_view_test.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -799,5 +799,33 @@ def test_form_submission
799799

800800
assert_equal "{\"path\":[\"action_menu.rb\"],\"value\":\"3\"}", response.dig("form_params", "folder_structure", 0)
801801
end
802+
803+
def test_keyboard_focus_moves_to_parent_when_include_fragment_with_role_treeitem_is_collapsed
804+
visit_preview(:loading_spinner)
805+
806+
# Tab to focus the tree view
807+
keyboard.type(:tab)
808+
809+
# Enter to expand the spinner node (which includes an include-fragment with role="treeitem")
810+
keyboard.type(:enter)
811+
812+
# Verify that the include-fragment has role="treeitem" when expanded
813+
assert_selector 'tree-view-include-fragment[role=treeitem]'
814+
815+
# Wait for the loader to be replaced
816+
assert_path("primer", "alpha")
817+
818+
# Navigate to a child node
819+
keyboard.type(:down)
820+
assert_path_selected("primer", "alpha")
821+
822+
# Now collapse the parent node using arrow keys while focus is on child
823+
keyboard.type(:up) # go back to parent
824+
keyboard.type(:left) # collapse
825+
826+
# Focus should move to the parent node and it should be tabbable
827+
assert_path_selected("primer")
828+
assert_path_tabbable("primer")
829+
end
802830
end
803831
end

0 commit comments

Comments
 (0)