Skip to content

Commit 773e344

Browse files
committed
Fix single-select toggle-off and form payload disambiguation
- Fix treeViewNodeChecked/Before events reporting wrong checkedValue on toggle-off (was hardcoded 'true', now reflects actual new state) - Align keyboard Space/Enter with click: pressing Space on an already-selected single-select node now deselects it - Include data-node-id as nodeId in hidden form input payload so duplicate-path nodes are distinguishable on the server side
1 parent 098da7b commit 773e344

7 files changed

Lines changed: 101 additions & 31 deletions

File tree

.changeset/all-paths-dance.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@
33
---
44

55
Fix selection of nodes with the same path in Primer::Alpha::TreeView
6+
7+
When a `[role=treeitem]` element carries a `data-node-id` attribute, that id is now included as `nodeId` in the hidden form input payload (`{path, nodeId?, value?}`). Trees with duplicate-path nodes should set `data-node-id` to a stable unique identifier so the server can distinguish which node was selected.
8+
9+
**Breaking change in `TreeViewElement#checkOnlyAtPath`:** if the given path is not found the method is now a no-op. Previously it would uncheck all active nodes before failing to check the missing node, which could be used as an indirect "clear selection" mechanism. Use explicit `setNodeCheckedValue(node, 'false')` calls if that behaviour is needed.

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

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,8 @@ export class TreeViewElement extends HTMLElement {
179179
// behavior for these element types is user- or browser-defined
180180
if (!(node instanceof HTMLDivElement)) return
181181

182-
const nodeInfo = this.infoFromNode(node, 'true')
182+
const newCheckedValue = this.getNodeCheckedValue(node) === 'true' ? 'false' : 'true'
183+
const nodeInfo = this.infoFromNode(node, newCheckedValue)
183184

184185
const checkSuccess = this.dispatchEvent(
185186
new CustomEvent('treeViewBeforeNodeChecked', {
@@ -191,7 +192,7 @@ export class TreeViewElement extends HTMLElement {
191192

192193
if (!checkSuccess) return
193194

194-
if (this.getNodeCheckedValue(node) === 'true') {
195+
if (newCheckedValue === 'false') {
195196
this.setNodeCheckedValue(node, 'false')
196197
} else {
197198
this.#checkNodeOnly(node)
@@ -270,7 +271,11 @@ export class TreeViewElement extends HTMLElement {
270271
} else if (this.selectVariant(node) === 'single') {
271272
event.preventDefault()
272273

273-
this.#checkNodeOnly(node)
274+
if (this.getNodeCheckedValue(node) === 'true') {
275+
this.setNodeCheckedValue(node, 'false')
276+
} else {
277+
this.#checkNodeOnly(node)
278+
}
274279
} else if (node instanceof HTMLAnchorElement) {
275280
// simulate click on space
276281
node.click()
@@ -312,7 +317,7 @@ export class TreeViewElement extends HTMLElement {
312317
}
313318

314319
get activeNodes() {
315-
return document.querySelectorAll('[aria-checked="true"]')
320+
return this.querySelectorAll('[aria-checked="true"]')
316321
}
317322

318323
expandAtPath(path: string[]) {
@@ -359,6 +364,7 @@ export class TreeViewElement extends HTMLElement {
359364

360365
#checkNodeOnly(node: Element) {
361366
for (const el of this.activeNodes) {
367+
if (el === node) continue
362368
this.setNodeCheckedValue(el, 'false')
363369
}
364370

@@ -485,10 +491,13 @@ export class TreeViewElement extends HTMLElement {
485491
newInput.removeAttribute('data-target')
486492
newInput.removeAttribute('form')
487493

488-
const payload: {path: string[]; value?: string} = {
494+
const payload: {path: string[]; nodeId?: string; value?: string} = {
489495
path: this.getNodePath(node),
490496
}
491497

498+
const nodeId = node.getAttribute('data-node-id')
499+
if (nodeId) payload.nodeId = nodeId
500+
492501
const inputValue = this.getFormInputValueForNode(node)
493502
if (inputValue) payload.value = inputValue
494503

previews/primer/alpha/tree_view_preview.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,12 @@ def form_input(select_variant: :multiple, expanded: true)
197197
})
198198
end
199199

200+
# @label Doubled paths
201+
# @hidden
202+
def doubled_path
203+
render_with_template
204+
end
205+
200206
private
201207

202208
def coerce_bool(value)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<div style="max-width: 400px">
2+
<%= render(Primer::Alpha::TreeView.new) do |tree_view| %>
3+
<% tree_view.with_sub_tree(label: "src", expanded: true, select_variant: :single) do |sub_tree| %>
4+
<% sub_tree.with_leaf(label: "button.rb", select_variant: :single) %>
5+
<% sub_tree.with_leaf(label: "icon_button.rb", current: true, select_variant: :single) %>
6+
<% end %>
7+
8+
<% tree_view.with_leaf(label: "action_menu.rb", select_variant: :single) %>
9+
<% tree_view.with_leaf(label: "action_menu.rb", select_variant: :single) %>
10+
<% tree_view.with_leaf(label: "action_menu.rb", select_variant: :single) %>
11+
<% end %>
12+
</div>

previews/primer/alpha/tree_view_preview/form_input.html.erb

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
<%= form_with(url: primer_view_components.generic_form_submission_path(format: :json)) do |f| %>
22
<%= render(Primer::Alpha::Stack.new) do %>
33
<%= render(Primer::Alpha::TreeView.new(form_arguments: { builder: f, name: "folder_structure" })) do |tree| %>
4-
<% tree.with_sub_tree(label: "src", expanded: expanded, select_variant: select_variant, value: 0) do |sub_tree| %>
5-
<% sub_tree.with_leaf(label: "button.rb", select_variant: select_variant, value: 1, checked: true) %>
6-
<% sub_tree.with_leaf(label: "icon_button.rb", current: true, select_variant: select_variant, value: 2) %>
4+
<% tree.with_sub_tree(label: "src", expanded: expanded, select_variant: select_variant, value: 0, data: { node_id: "src" }) do |sub_tree| %>
5+
<% sub_tree.with_leaf(label: "button.rb", select_variant: select_variant, value: 1, checked: true, data: { node_id: "src-button-rb" }) %>
6+
<% sub_tree.with_leaf(label: "icon_button.rb", current: true, select_variant: select_variant, value: 2, data: { node_id: "src-icon-button-rb" }) %>
77
<% end %>
88

9-
<% tree.with_leaf(label: "action_menu.rb", select_variant: select_variant, value: 3) %>
10-
<% tree.with_sub_tree(label: "Docs & legal requirements", select_variant: select_variant, value: 4) do |sub_tree| %>
11-
<% sub_tree.with_leaf(label: "Readme.md", select_variant: select_variant, value: 5) %>
12-
<% sub_tree.with_leaf(label: "Copyright.md", select_variant: select_variant, value: 6) %>
9+
<% tree.with_leaf(label: "action_menu.rb", select_variant: select_variant, value: 3, data: { node_id: "action-menu-rb" }) %>
10+
<% tree.with_sub_tree(label: "Docs & legal requirements", select_variant: select_variant, value: 4, data: { node_id: "docs" }) do |sub_tree| %>
11+
<% sub_tree.with_leaf(label: "Readme.md", select_variant: select_variant, value: 5, data: { node_id: "docs-readme-md" }) %>
12+
<% sub_tree.with_leaf(label: "Copyright.md", select_variant: select_variant, value: 6, data: { node_id: "docs-copyright-md" }) %>
1313
<% end %>
1414
<% end %>
1515

previews/primer/alpha/tree_view_preview/single_select.html.erb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,5 @@
66
<% end %>
77

88
<% tree_view.with_leaf(label: "action_menu.rb", disabled: disabled, select_variant: select_variant) %>
9-
<% tree_view.with_leaf(label: "action_menu.rb", disabled: disabled, select_variant: select_variant) %>
109
<% end %>
1110
</div>

test/system/alpha/tree_view_test.rb

Lines changed: 58 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -653,37 +653,65 @@ def test_fires_check_event_after_single_variant
653653
end
654654

655655
def test_single_select_with_duplicate_paths_selects_clicked_node
656-
visit_preview(:single_select)
656+
visit_preview(:doubled_path)
657657

658658
nodes = all(selector_for("action_menu.rb"))
659-
assert_equal 2, nodes.size
659+
assert_equal 3, nodes.size
660660

661661
nodes[1].click
662662

663-
assert_equal "true", nodes[1]["aria-checked"], "The clicked (second) node should be selected"
664-
assert_equal "false", nodes[0]["aria-checked"], "The first node with the same path should not be selected"
663+
nodes[1].assert_matches_selector("[aria-checked='true']")
664+
nodes[0].assert_matches_selector("[aria-checked='false']")
665665

666666
nodes[0].click
667667

668-
assert_equal "true", nodes[0]["aria-checked"], "The clicked (first) node should be selected"
669-
assert_equal "false", nodes[1]["aria-checked"], "The second node with the same path should not be selected"
668+
nodes[0].assert_matches_selector("[aria-checked='true']")
669+
nodes[1].assert_matches_selector("[aria-checked='false']")
670670
end
671671

672672
def test_single_select_with_duplicate_paths_selects_focused_node_on_keyboard
673-
visit_preview(:single_select)
673+
visit_preview(:doubled_path)
674674

675675
nodes = all(selector_for("action_menu.rb"))
676-
assert_equal 2, nodes.size
676+
assert_equal 3, nodes.size
677677

678678
nodes[1].send_keys(:space)
679679

680-
assert_equal "true", nodes[1]["aria-checked"], "The focused (second) node should be selected via keyboard"
681-
assert_equal "false", nodes[0]["aria-checked"], "The first node with the same path should not be selected"
680+
nodes[1].assert_matches_selector("[aria-checked='true']")
681+
nodes[0].assert_matches_selector("[aria-checked='false']")
682682

683683
nodes[0].send_keys(:space)
684684

685-
assert_equal "true", nodes[0]["aria-checked"], "The focused (first) node should be selected via keyboard"
686-
assert_equal "false", nodes[1]["aria-checked"], "The second node with the same path should not be selected"
685+
nodes[0].assert_matches_selector("[aria-checked='true']")
686+
nodes[1].assert_matches_selector("[aria-checked='false']")
687+
end
688+
689+
def test_fires_check_event_after_single_variant_toggle_off
690+
visit_preview(:default, select_variant: :single)
691+
692+
activate_at_path("src")
693+
assert_path_checked "src"
694+
695+
details = capture_event("treeViewNodeChecked") do
696+
activate_at_path("src")
697+
end
698+
699+
assert_equal 1, details.size
700+
assert details[0]["node"]
701+
assert_equal details[0]["path"], ["src"]
702+
assert_equal details[0]["checkedValue"], "false"
703+
assert_equal details[0]["previousCheckedValue"], "true"
704+
end
705+
706+
def test_keyboard_toggles_off_already_selected_single_variant
707+
visit_preview(:doubled_path)
708+
709+
nodes = all(selector_for("action_menu.rb"))
710+
nodes[0].send_keys(:space)
711+
nodes[0].assert_matches_selector("[aria-checked='true']")
712+
713+
nodes[0].send_keys(:space)
714+
nodes[0].assert_matches_selector("[aria-checked='false']")
687715
end
688716

689717
def test_fires_event_before_checking
@@ -876,8 +904,8 @@ def test_form_submission
876904
# for some reason the JSON response is wrapped in HTML, I have no idea why
877905
response = JSON.parse(find("pre").text)
878906

879-
assert_equal "{\"path\":[\"src\",\"button.rb\"],\"value\":\"1\"}", response.dig("form_params", "folder_structure", 0)
880-
assert_equal "{\"path\":[\"action_menu.rb\"],\"value\":\"3\"}", response.dig("form_params", "folder_structure", 1)
907+
assert_equal "{\"path\":[\"src\",\"button.rb\"],\"nodeId\":\"src-button-rb\",\"value\":\"1\"}", response.dig("form_params", "folder_structure", 0)
908+
assert_equal "{\"path\":[\"action_menu.rb\"],\"nodeId\":\"action-menu-rb\",\"value\":\"3\"}", response.dig("form_params", "folder_structure", 1)
881909
end
882910

883911
def test_initial_form_state
@@ -890,7 +918,7 @@ def test_initial_form_state
890918
# for some reason the JSON response is wrapped in HTML, I have no idea why
891919
response = JSON.parse(find("pre").text)
892920

893-
assert_equal "{\"path\":[\"src\",\"button.rb\"],\"value\":\"1\"}", response.dig("form_params", "folder_structure", 0)
921+
assert_equal "{\"path\":[\"src\",\"button.rb\"],\"nodeId\":\"src-button-rb\",\"value\":\"1\"}", response.dig("form_params", "folder_structure", 0)
894922
end
895923

896924
def test_form_submission_with_single_select_variant
@@ -904,7 +932,7 @@ def test_form_submission_with_single_select_variant
904932
# for some reason the JSON response is wrapped in HTML, I have no idea why
905933
response = JSON.parse(find("pre").text)
906934

907-
assert_equal "{\"path\":[\"action_menu.rb\"],\"value\":\"3\"}", response.dig("form_params", "folder_structure", 0)
935+
assert_equal "{\"path\":[\"action_menu.rb\"],\"nodeId\":\"action-menu-rb\",\"value\":\"3\"}", response.dig("form_params", "folder_structure", 0)
908936
end
909937

910938
def test_initial_form_state_for_single_select
@@ -917,7 +945,19 @@ def test_initial_form_state_for_single_select
917945
# for some reason the JSON response is wrapped in HTML, I have no idea why
918946
response = JSON.parse(find("pre").text)
919947

920-
assert_equal "{\"path\":[\"src\",\"button.rb\"],\"value\":\"1\"}", response.dig("form_params", "folder_structure", 0)
948+
assert_equal "{\"path\":[\"src\",\"button.rb\"],\"nodeId\":\"src-button-rb\",\"value\":\"1\"}", response.dig("form_params", "folder_structure", 0)
949+
end
950+
951+
def test_form_payload_includes_node_id_when_present
952+
visit_preview(:form_input, expanded: true, select_variant: :single, route_format: :json)
953+
954+
activate_at_path("action_menu.rb")
955+
956+
find("button[type=submit]").click
957+
958+
response = JSON.parse(find("pre").text)
959+
960+
assert_equal "{\"path\":[\"action_menu.rb\"],\"nodeId\":\"action-menu-rb\",\"value\":\"3\"}", response.dig("form_params", "folder_structure", 0)
921961
end
922962

923963
def test_single_select_with_special_character
@@ -932,7 +972,7 @@ def test_single_select_with_special_character
932972
# for some reason the JSON response is wrapped in HTML, I have no idea why
933973
response = JSON.parse(find("pre").text)
934974

935-
assert_equal "{\"path\":[\"Docs & legal requirements\"],\"value\":\"4\"}", response.dig("form_params", "folder_structure", 0)
975+
assert_equal "{\"path\":[\"Docs & legal requirements\"],\"nodeId\":\"docs\",\"value\":\"4\"}", response.dig("form_params", "folder_structure", 0)
936976
end
937977

938978
def test_keyboard_focus_moves_to_parent_when_include_fragment_with_role_treeitem_is_collapsed

0 commit comments

Comments
 (0)