Skip to content

Commit 7801fd3

Browse files
committed
Implement keyboard support for single select_variant for TreeView and add some tests
1 parent 42a2411 commit 7801fd3

8 files changed

Lines changed: 169 additions & 33 deletions

File tree

app/components/primer/alpha/tree_view.pcss

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,7 @@
197197
}
198198
}
199199

200-
&[aria-checked='false'],
201-
&[aria-selected='false'] {
200+
&[aria-checked='false'] {
202201
/* singleselect checkmark */
203202
& .TreeViewItem-singleSelectCheckmark {
204203
visibility: hidden;

app/components/primer/alpha/tree_view.rb

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,14 @@ module Alpha
240240
#
241241
# Nodes can be checked via the keyboard by pressing the space key.
242242
#
243+
# ## Single-select mode
244+
#
245+
# By passing `select_variant: :single` to both sub-tree and leaf nodes:
246+
# - Nodes become selectable and can be toggled via keyboard (space key).
247+
# - A selected node displays a checkmark at the end of the line.
248+
# Note: This checkmark conflicts with the `trailing_visual_icon` slot,
249+
# so both cannot be used simultaneously.
250+
#
243251
# ## Node tags
244252
#
245253
# `TreeView`s support three different node variants, `:anchor`, `:button`, and `:div` (the default), which controls
@@ -259,13 +267,19 @@ module Alpha
259267
# |:---------------|:-------------|:------------|:--------------------------|
260268
# |Enter/space |none |div |Expands/collapses |
261269
# |Enter/space |none |anchor/button|Activates anchor/button |
270+
# |Enter/space |single |div |Selects |
271+
# |Enter/space |single |anchor/button|N/A (not allowed) |
262272
# |Enter/space |multiple |div |Checks or unchecks |
263273
# |Enter/space |multiple |anchor/button|N/A (not allowed) |
264274
# |Left/right arrow|none |div |Expands/collapses |
265275
# |Left/right arrow|none |anchor/button|Expands/collapses |
276+
# |Left/right arrow|single |div |Expands/collapses |
277+
# |Left/right arrow|single |anchor/button|N/A (not allowed) |
266278
# |Left/right arrow|multiple |div |Expands/collapses |
267279
# |Left/right arrow|multiple |anchor/button|N/A (not allowed) |
268280
# |Click |none |div |Expands/collapses |
281+
# |Click |single |div |Selects |
282+
# |Click |single |anchor/button|N/A (not allowed) |
269283
# |Click |multiple |div |Checks or unchecks |
270284
# |Click |multiple |anchor/button|N/A (not allowed) |
271285
#
@@ -351,7 +365,7 @@ module Alpha
351365
# )
352366
# ```
353367
#
354-
# Because checking or unchecking a sub-tree results in the checking or unchecking of all its children recursively,
368+
# Because checking or unchecking a sub-tree may result in the checking or unchecking of all its children recursively,
355369
# both the `treeViewNodeChecked` and `treeViewBeforeNodeChecked` events provide an array of `TreeViewNodeInfo`
356370
# objects, which contain entries for every modified node in the tree.
357371
class TreeView < Primer::Component

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

Lines changed: 62 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import {controller, target} from '@github/catalyst'
2-
import {SelectStrategy, TreeViewSubTreeNodeElement} from './tree_view_sub_tree_node_element'
3-
import {useRovingTabIndex} from './tree_view_roving_tab_index'
4-
import type {TreeViewNodeType, TreeViewCheckedValue, TreeViewNodeInfo} from '../../shared_events'
5-
6-
export type SelectVariant = 'none' | 'single' | 'multiple'
1+
import { controller, target } from '@github/catalyst'
2+
import { SelectStrategy, SelectVariant, TreeViewSubTreeNodeElement } from './tree_view_sub_tree_node_element'
3+
import { useRovingTabIndex } from './tree_view_roving_tab_index'
4+
import type { TreeViewCheckedValue, TreeViewNodeInfo, TreeViewNodeType } from '../../shared_events'
75

86
@controller
97
export class TreeViewElement extends HTMLElement {
@@ -126,6 +124,8 @@ export class TreeViewElement extends HTMLElement {
126124
#handleNodeEvent(node: Element, event: Event) {
127125
if (this.#eventIsCheckboxToggle(event, node)) {
128126
this.#handleCheckboxToggle(event, node)
127+
} else if (this.#eventIsSingleSelection(event, node)) {
128+
this.handleSingleSelection(event, node)
129129
} else if (this.#eventIsActivation(event)) {
130130
this.#handleNodeActivated(event, node)
131131
} else if (event.type === 'focusin') {
@@ -179,6 +179,48 @@ export class TreeViewElement extends HTMLElement {
179179
)
180180
}
181181

182+
#eventIsSingleSelection(event: Event, node: Element) {
183+
return event.type === 'click' && this.selectVariant(node) === 'single'
184+
}
185+
186+
handleSingleSelection(event: Event, node: Element) {
187+
if (this.getNodeDisabledValue(node)) {
188+
event.preventDefault()
189+
return
190+
}
191+
192+
// do not emit activation events for buttons and anchors, since it is assumed any activation
193+
// behavior for these element types is user- or browser-defined
194+
if (!(node instanceof HTMLDivElement)) return
195+
196+
const path = this.getNodePath(node)
197+
const nodeInfo = this.infoFromNode(node, 'true')
198+
199+
const checkSuccess = this.dispatchEvent(
200+
new CustomEvent('treeViewBeforeNodeChecked', {
201+
bubbles: true,
202+
cancelable: true,
203+
detail: [nodeInfo],
204+
}),
205+
)
206+
207+
if (!checkSuccess) return
208+
209+
const currentlyChecked = !this.getNodeCheckedValue(node)
210+
211+
// disallow unchecking checked item in single-select mode
212+
if (!currentlyChecked) {
213+
this.checkOnlyAtPath(path)
214+
}
215+
216+
this.dispatchEvent(
217+
new CustomEvent('treeViewNodeChecked', {
218+
bubbles: true,
219+
detail: [this.infoFromNode(node, 'true')],
220+
}),
221+
)
222+
}
223+
182224
#handleNodeActivated(event: Event, node: Element) {
183225
if (this.getNodeDisabledValue(node)) {
184226
event.preventDefault()
@@ -201,19 +243,6 @@ export class TreeViewElement extends HTMLElement {
201243

202244
if (!activationSuccess) return
203245

204-
if (this.selectVariant(node) === 'single') {
205-
const currentlyChecked = !this.getNodeCheckedValue(node)
206-
207-
// disallow unchecking checked item in single-select mode
208-
if (!currentlyChecked) {
209-
for (const el of this.activeNodes) {
210-
this.uncheckAtPath(this.getNodePath(el))
211-
}
212-
213-
this.checkAtPath(path)
214-
}
215-
}
216-
217246
// navigate or trigger button, don't toggle
218247
if (!this.nodeHasNativeAction(node)) {
219248
this.toggleAtPath(path)
@@ -246,14 +275,18 @@ export class TreeViewElement extends HTMLElement {
246275
break
247276
}
248277

249-
if (this.nodeHasCheckBox(node)) {
278+
if (this.selectVariant(node) == 'multiple') {
250279
event.preventDefault()
251280

252281
if (this.getNodeCheckedValue(node) === 'true') {
253282
this.setNodeCheckedValue(node, 'false')
254283
} else {
255284
this.setNodeCheckedValue(node, 'true')
256285
}
286+
} else if (this.selectVariant(node) == 'single') {
287+
event.preventDefault()
288+
289+
this.checkOnlyAtPath(this.getNodePath(node))
257290
} else if (node instanceof HTMLAnchorElement) {
258291
// simulate click on space
259292
node.click()
@@ -333,6 +366,14 @@ export class TreeViewElement extends HTMLElement {
333366
this.setNodeCheckedValue(node, 'false')
334367
}
335368

369+
checkOnlyAtPath(path: string[]) {
370+
for (const el of this.activeNodes) {
371+
this.uncheckAtPath(this.getNodePath(el))
372+
}
373+
374+
this.checkAtPath(path)
375+
}
376+
336377
toggleCheckedAtPath(path: string[]) {
337378
const node = this.nodeAtPath(path)
338379
if (!node) return
@@ -440,7 +481,7 @@ export class TreeViewElement extends HTMLElement {
440481
}
441482
}
442483

443-
selectVariant(node: HTMLElement): SelectVariant {
484+
selectVariant(node: Element): SelectVariant {
444485
return (node.getAttribute('data-select-variant') || 'none') as SelectVariant
445486
}
446487
}

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

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1-
import {controller, target} from '@github/catalyst'
2-
import {TreeViewIconPairElement} from './tree_view_icon_pair_element'
3-
import {observeMutationsUntilConditionMet} from '../../utils'
4-
import {TreeViewIncludeFragmentElement} from './tree_view_include_fragment_element'
5-
import {TreeViewElement} from './tree_view'
6-
import type {TreeViewNodeInfo} from '../../shared_events'
1+
import { controller, target } from '@github/catalyst'
2+
import { TreeViewIconPairElement } from './tree_view_icon_pair_element'
3+
import { observeMutationsUntilConditionMet } from '../../utils'
4+
import { TreeViewIncludeFragmentElement } from './tree_view_include_fragment_element'
5+
import { TreeViewElement } from './tree_view'
6+
import type { TreeViewNodeInfo } from '../../shared_events'
77

88
type LoadingState = 'loading' | 'error' | 'success'
99

1010
export type SelectStrategy = 'self' | 'descendants' | 'mixed_descendants'
1111

12+
export type SelectVariant = 'none' | 'single' | 'multiple'
13+
1214
@controller
1315
export class TreeViewSubTreeNodeElement extends HTMLElement {
1416
@target node: HTMLElement
@@ -133,6 +135,10 @@ export class TreeViewSubTreeNodeElement extends HTMLElement {
133135
return (this.node.getAttribute('data-select-strategy') || 'descendants') as SelectStrategy
134136
}
135137

138+
get selectVariant(): SelectVariant {
139+
return (this.node.getAttribute('data-select-variant') || 'none') as SelectVariant
140+
}
141+
136142
get level(): number {
137143
return parseInt(this.node.getAttribute('aria-level') || '0')
138144
}
@@ -321,6 +327,9 @@ export class TreeViewSubTreeNodeElement extends HTMLElement {
321327

322328
if (this.#checkboxElement) {
323329
this.toggleChecked()
330+
} else if (this.selectVariant == 'single') {
331+
// Follow the standard implementation of TreeView and select that item
332+
this.treeView.handleSingleSelection(event, node)
324333
} else if (!this.treeView?.nodeHasNativeAction(node)) {
325334
// toggle only if this node isn't eg. an anchor or button
326335
this.toggle()
@@ -352,6 +361,9 @@ export class TreeViewSubTreeNodeElement extends HTMLElement {
352361
event.preventDefault()
353362

354363
this.toggleChecked()
364+
} else if (this.selectVariant == 'single') {
365+
// Follow the standard implementation of TreeView and select that item
366+
this.treeView.handleSingleSelection(event, node)
355367
} else {
356368
if (node instanceof HTMLAnchorElement) {
357369
// simulate click on space for anchors (buttons already handle this natively)

previews/primer/alpha/tree_view_preview.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ def auto_expansion
185185

186186
# @label Form input
187187
#
188-
# @param select_variant [Symbol] select [multiple, single, none]
188+
# @param select_variant [Symbol] select [multiple, single]
189189
# @param expanded [Boolean] toggle
190190
def form_input(select_variant: :multiple, expanded: true)
191191
render_with_template(locals: {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<% icons.with_collapsed_icon(icon: :"file-directory-fill", color: :accent) %>
77
<% end %>
88

9-
<% sub_tree.with_trailing_visual_icon(icon: :"diff-modified") %>
9+
<% sub_tree.with_trailing_visual_icon(icon: :"diff-modified") unless select_variant == :single %>
1010

1111
<% sub_tree.with_leaf(label: "button.rb", disabled: disabled, select_variant: select_variant) do |item| %>
1212
<% item.with_leading_visual_icon(icon: :file) %>

test/components/primer/alpha/tree_view_test.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,19 @@ def test_falls_back_to_div_variant_when_invalid_variant_given
193193

194194
assert_selector "div[role=treeitem]", text: "Foobar"
195195
end
196+
197+
def test_disallows_trailing_visuals_when_single_select_variant_is_used
198+
error = assert_raises(ArgumentError) do
199+
render_inline(Primer::Alpha::TreeView.new) do |tree|
200+
tree.with_sub_tree(label: "src", select_variant: :single) do |sub_tree|
201+
sub_tree.with_trailing_visual_icon(icon: :"diff-modified")
202+
sub_tree.with_leaf(label: "button.rb")
203+
end
204+
end
205+
end
206+
207+
assert_equal error.message, "Trailing visuals can't be used in combination with single select mode as the icon is reserved."
208+
end
196209
end
197210
end
198211
end

test/system/alpha/tree_view_test.rb

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,51 @@ def test_fires_activation_event
607607
assert_equal details["previousCheckedValue"], "false"
608608
end
609609

610+
def test_fires_event_before_checking_single_variant
611+
visit_preview(:default, select_variant: :single)
612+
613+
details = capture_event("treeViewBeforeNodeChecked") do
614+
activate_at_path("src")
615+
end
616+
617+
assert_equal details.size, 1
618+
619+
assert details[0]["node"]
620+
assert_equal details[0]["type"], "sub-tree"
621+
assert_equal details[0]["path"], ["src"]
622+
assert_equal details[0]["checkedValue"], "true"
623+
assert_equal details[0]["previousCheckedValue"], "false"
624+
end
625+
626+
def test_canceling_check_event_prevents_checking_for_single_variant
627+
visit_preview(:default, select_variant: :single)
628+
629+
refute_path_checked "src"
630+
631+
capture_event("treeViewBeforeNodeChecked", cancel: true) do
632+
activate_at_path("src")
633+
end
634+
635+
# src should still not be checked
636+
refute_path_checked "src"
637+
end
638+
639+
def test_fires_check_event_after_single_variant
640+
visit_preview(:default, select_variant: :single)
641+
642+
details = capture_event("treeViewNodeChecked") do
643+
activate_at_path("src")
644+
end
645+
646+
assert_equal details.size, 1
647+
648+
assert details[0]["node"]
649+
assert_equal details[0]["type"], "sub-tree"
650+
assert_equal details[0]["path"], ["src"]
651+
assert_equal details[0]["checkedValue"], "true"
652+
assert_equal details[0]["previousCheckedValue"], "false"
653+
end
654+
610655
def test_fires_event_before_checking
611656
visit_preview(:default, select_variant: :multiple)
612657

@@ -786,7 +831,6 @@ def test_self_select_strategy_checking_sub_tree_does_not_check_children
786831
assert_path_checked "primer", "alpha", "action_bar"
787832
end
788833

789-
790834
def test_form_submission
791835
visit_preview(:form_input, expanded: true, route_format: :json)
792836

@@ -799,5 +843,18 @@ def test_form_submission
799843

800844
assert_equal "{\"path\":[\"action_menu.rb\"],\"value\":\"3\"}", response.dig("form_params", "folder_structure", 0)
801845
end
846+
847+
def test_form_submission_with_single_select_variant
848+
visit_preview(:form_input, expanded: true, select_variant: :single, route_format: :json)
849+
850+
check_at_path("action_menu.rb")
851+
852+
find("button[type=submit]").click
853+
854+
# for some reason the JSON response is wrapped in HTML, I have no idea why
855+
response = JSON.parse(find("pre").text)
856+
857+
assert_equal "{\"path\":[\"action_menu.rb\"],\"value\":\"3\"}", response.dig("form_params", "folder_structure", 0)
858+
end
802859
end
803860
end

0 commit comments

Comments
 (0)