diff --git a/.changeset/nasty-moles-sip.md b/.changeset/nasty-moles-sip.md new file mode 100644 index 0000000000..341a96b761 --- /dev/null +++ b/.changeset/nasty-moles-sip.md @@ -0,0 +1,5 @@ +--- +"@primer/view-components": patch +--- + +chore(treeview): add treeitem role to shadow dom node diff --git a/app/components/primer/alpha/tree_view/tree_view_sub_tree_node_element.ts b/app/components/primer/alpha/tree_view/tree_view_sub_tree_node_element.ts index 5d2b45f41f..7201aa970a 100644 --- a/app/components/primer/alpha/tree_view/tree_view_sub_tree_node_element.ts +++ b/app/components/primer/alpha/tree_view/tree_view_sub_tree_node_element.ts @@ -178,6 +178,12 @@ export class TreeViewSubTreeNodeElement extends HTMLElement { // sub-tree and no node in the entire tree can be focused const previousNode = this.subTree.querySelector("[tabindex='0']") previousNode?.setAttribute('tabindex', '-1') + + // Also check if the subtree element itself is an include-fragment with role="treeitem" and has focus + if (this.#isIncludeFragment() && this.subTree.getAttribute('tabindex') === '0') { + this.subTree.setAttribute('tabindex', '-1') + } + this.node.setAttribute('tabindex', '0') this.treeView.dispatchEvent( @@ -263,6 +269,10 @@ export class TreeViewSubTreeNodeElement extends HTMLElement { // request succeeded but element has not yet been replaced case 'include-fragment-replace': this.#activeElementIsLoader = document.activeElement === this.loadingIndicator.closest('[role=treeitem]') + // Also check if the include-fragment itself has focus (when it has role="treeitem") + if (!this.#activeElementIsLoader && document.activeElement === this.subTree && this.#isIncludeFragment()) { + this.#activeElementIsLoader = true + } this.loadingState = 'success' break @@ -410,6 +420,13 @@ export class TreeViewSubTreeNodeElement extends HTMLElement { #update() { if (this.expanded) { if (this.subTree) this.subTree.hidden = false + if (this.#isIncludeFragment()) { + this.subTree.setAttribute('role', 'treeitem') + // Ensure the include-fragment can participate in roving tab index + if (!this.subTree.hasAttribute('tabindex')) { + this.subTree.setAttribute('tabindex', '-1') + } + } this.node.setAttribute('aria-expanded', 'true') this.treeView?.expandAncestorsForNode(this) @@ -423,6 +440,11 @@ export class TreeViewSubTreeNodeElement extends HTMLElement { } } else { if (this.subTree) this.subTree.hidden = true + if (this.#isIncludeFragment()) { + this.subTree.removeAttribute('role') + // Remove tabindex when role is removed + this.subTree.removeAttribute('tabindex') + } this.node.setAttribute('aria-expanded', 'false') if (this.iconPair) { @@ -453,6 +475,10 @@ export class TreeViewSubTreeNodeElement extends HTMLElement { } } + #isIncludeFragment(): boolean { + return this.subTree?.getAttribute('data-target')?.includes('tree-view-sub-tree-node.includeFragment') ?? false + } + get #checkboxElement(): HTMLElement | null { return this.querySelector('.TreeViewItemCheckbox') } diff --git a/test/system/alpha/tree_view_test.rb b/test/system/alpha/tree_view_test.rb index e1002c36e6..d94bb550b6 100644 --- a/test/system/alpha/tree_view_test.rb +++ b/test/system/alpha/tree_view_test.rb @@ -799,5 +799,33 @@ def test_form_submission assert_equal "{\"path\":[\"action_menu.rb\"],\"value\":\"3\"}", response.dig("form_params", "folder_structure", 0) end + + def test_keyboard_focus_moves_to_parent_when_include_fragment_with_role_treeitem_is_collapsed + visit_preview(:loading_spinner) + + # Tab to focus the tree view + keyboard.type(:tab) + + # Enter to expand the spinner node (which includes an include-fragment with role="treeitem") + keyboard.type(:enter) + + # Verify that the include-fragment has role="treeitem" when expanded + assert_selector 'tree-view-include-fragment[role=treeitem]' + + # Wait for the loader to be replaced + assert_path("primer", "alpha") + + # Navigate to a child node + keyboard.type(:down) + assert_path_selected("primer", "alpha") + + # Now collapse the parent node using arrow keys while focus is on child + keyboard.type(:up) # go back to parent + keyboard.type(:left) # collapse + + # Focus should move to the parent node and it should be tabbable + assert_path_selected("primer") + assert_path_tabbable("primer") + end end end