diff --git a/.changeset/full-states-beg.md b/.changeset/full-states-beg.md
new file mode 100644
index 0000000000..0347022f54
--- /dev/null
+++ b/.changeset/full-states-beg.md
@@ -0,0 +1,5 @@
+---
+'@primer/view-components': minor
+---
+
+Support single selection variant for TreeView
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000000..7c4836fbe3
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000000..3916080457
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/.idea/primer_view_components.iml b/.idea/primer_view_components.iml
new file mode 100644
index 0000000000..f8017aa776
--- /dev/null
+++ b/.idea/primer_view_components.iml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000000..35eb1ddfbb
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
new file mode 100644
index 0000000000..4000edcbbb
--- /dev/null
+++ b/.idea/workspace.xml
@@ -0,0 +1,277 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ "associatedIndex": 6
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1748258548189
+
+
+ 1748258548189
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/aria-snapshot--after-interaction.yml b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/aria-snapshot--after-interaction.yml
new file mode 100644
index 0000000000..665d7e6a46
--- /dev/null
+++ b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/aria-snapshot--after-interaction.yml
@@ -0,0 +1,3 @@
+- tree:
+ - treeitem "src" [checked] [level=1] [selected]
+ - treeitem "action_menu.rb" [level=1]
\ No newline at end of file
diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/aria-snapshot.yml b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/aria-snapshot.yml
new file mode 100644
index 0000000000..4a15b2ed7d
--- /dev/null
+++ b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/aria-snapshot.yml
@@ -0,0 +1,3 @@
+- tree:
+ - treeitem "src" [level=1]
+ - treeitem "action_menu.rb" [level=1]
\ No newline at end of file
diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/dark.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/dark.png
new file mode 100644
index 0000000000..5e16e90c4d
Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/dark.png differ
diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/dark_colorblind.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/dark_colorblind.png
new file mode 100644
index 0000000000..5e16e90c4d
Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/dark_colorblind.png differ
diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/dark_dimmed.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/dark_dimmed.png
new file mode 100644
index 0000000000..71074aca2d
Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/dark_dimmed.png differ
diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/dark_high_contrast.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/dark_high_contrast.png
new file mode 100644
index 0000000000..1f60736f87
Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/dark_high_contrast.png differ
diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/default.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/default.png
new file mode 100644
index 0000000000..5078c5fc5c
Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/default.png differ
diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/focused.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/focused.png
new file mode 100644
index 0000000000..f19e9e2d1c
Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/focused.png differ
diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/light.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/light.png
new file mode 100644
index 0000000000..dd5c10e341
Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/light.png differ
diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/light_colorblind.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/light_colorblind.png
new file mode 100644
index 0000000000..dd5c10e341
Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/light_colorblind.png differ
diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/light_high_contrast.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/light_high_contrast.png
new file mode 100644
index 0000000000..fa672aa1f1
Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/multi_select/light_high_contrast.png differ
diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/aria-snapshot--after-interaction.yml b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/aria-snapshot--after-interaction.yml
new file mode 100644
index 0000000000..665d7e6a46
--- /dev/null
+++ b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/aria-snapshot--after-interaction.yml
@@ -0,0 +1,3 @@
+- tree:
+ - treeitem "src" [checked] [level=1] [selected]
+ - treeitem "action_menu.rb" [level=1]
\ No newline at end of file
diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/aria-snapshot.yml b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/aria-snapshot.yml
new file mode 100644
index 0000000000..4a15b2ed7d
--- /dev/null
+++ b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/aria-snapshot.yml
@@ -0,0 +1,3 @@
+- tree:
+ - treeitem "src" [level=1]
+ - treeitem "action_menu.rb" [level=1]
\ No newline at end of file
diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/dark.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/dark.png
new file mode 100644
index 0000000000..d0433c1588
Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/dark.png differ
diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/dark_colorblind.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/dark_colorblind.png
new file mode 100644
index 0000000000..d0433c1588
Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/dark_colorblind.png differ
diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/dark_dimmed.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/dark_dimmed.png
new file mode 100644
index 0000000000..75729dd4de
Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/dark_dimmed.png differ
diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/dark_high_contrast.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/dark_high_contrast.png
new file mode 100644
index 0000000000..9521a0e645
Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/dark_high_contrast.png differ
diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/default.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/default.png
new file mode 100644
index 0000000000..6036d10df6
Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/default.png differ
diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/focused.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/focused.png
new file mode 100644
index 0000000000..5ec645eafe
Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/focused.png differ
diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/light.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/light.png
new file mode 100644
index 0000000000..1548a0e42f
Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/light.png differ
diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/light_colorblind.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/light_colorblind.png
new file mode 100644
index 0000000000..1548a0e42f
Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/light_colorblind.png differ
diff --git a/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/light_high_contrast.png b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/light_high_contrast.png
new file mode 100644
index 0000000000..0fd64ceef6
Binary files /dev/null and b/.playwright/screenshots/snapshots.test.ts-snapshots/primer/alpha/tree_view/single_select/light_high_contrast.png differ
diff --git a/app/components/primer/alpha/tree_view.pcss b/app/components/primer/alpha/tree_view.pcss
index 01685aaab3..a410fc4932 100644
--- a/app/components/primer/alpha/tree_view.pcss
+++ b/app/components/primer/alpha/tree_view.pcss
@@ -197,6 +197,13 @@
}
}
+ &[aria-checked='false'] {
+ /* singleselect checkmark */
+ & .TreeViewItem-singleSelectCheckmark {
+ visibility: hidden;
+ }
+ }
+
&[aria-checked='mixed'] {
& .FormControl-checkbox {
background: var(--control-checked-bgColor-rest);
diff --git a/app/components/primer/alpha/tree_view.rb b/app/components/primer/alpha/tree_view.rb
index 903c5aa398..e93655b12d 100644
--- a/app/components/primer/alpha/tree_view.rb
+++ b/app/components/primer/alpha/tree_view.rb
@@ -240,6 +240,14 @@ module Alpha
#
# Nodes can be checked via the keyboard by pressing the space key.
#
+ # ## Single-select mode
+ #
+ # By passing `select_variant: :single` to both sub-tree and leaf nodes:
+ # - Nodes become selectable and can be toggled via keyboard (space key).
+ # - A selected node displays a checkmark at the end of the line.
+ # Note: This checkmark conflicts with the `trailing_visual_icon` slot,
+ # so both cannot be used simultaneously.
+ #
# ## Node tags
#
# `TreeView`s support three different node variants, `:anchor`, `:button`, and `:div` (the default), which controls
@@ -259,13 +267,19 @@ module Alpha
# |:---------------|:-------------|:------------|:--------------------------|
# |Enter/space |none |div |Expands/collapses |
# |Enter/space |none |anchor/button|Activates anchor/button |
+ # |Enter/space |single |div |Selects |
+ # |Enter/space |single |anchor/button|N/A (not allowed) |
# |Enter/space |multiple |div |Checks or unchecks |
# |Enter/space |multiple |anchor/button|N/A (not allowed) |
# |Left/right arrow|none |div |Expands/collapses |
# |Left/right arrow|none |anchor/button|Expands/collapses |
+ # |Left/right arrow|single |div |Expands/collapses |
+ # |Left/right arrow|single |anchor/button|N/A (not allowed) |
# |Left/right arrow|multiple |div |Expands/collapses |
# |Left/right arrow|multiple |anchor/button|N/A (not allowed) |
# |Click |none |div |Expands/collapses |
+ # |Click |single |div |Selects |
+ # |Click |single |anchor/button|N/A (not allowed) |
# |Click |multiple |div |Checks or unchecks |
# |Click |multiple |anchor/button|N/A (not allowed) |
#
@@ -351,7 +365,7 @@ module Alpha
# )
# ```
#
- # Because checking or unchecking a sub-tree results in the checking or unchecking of all its children recursively,
+ # Because checking or unchecking a sub-tree may result in the checking or unchecking of all its children recursively,
# both the `treeViewNodeChecked` and `treeViewBeforeNodeChecked` events provide an array of `TreeViewNodeInfo`
# objects, which contain entries for every modified node in the tree.
class TreeView < Primer::Component
diff --git a/app/components/primer/alpha/tree_view/node.html.erb b/app/components/primer/alpha/tree_view/node.html.erb
index 2c4627172e..0ccd51ce7d 100644
--- a/app/components/primer/alpha/tree_view/node.html.erb
+++ b/app/components/primer/alpha/tree_view/node.html.erb
@@ -23,7 +23,11 @@
<%= leading_visual %>
<% end %>
<%= text_content %>
- <% if trailing_visual? %>
+ <% if @select_variant == :single %>
+
+ <%= render(Primer::Beta::Octicon.new(icon: :check, classes: "TreeViewItem-singleSelectCheckmark")) %>
+
+ <% elsif trailing_visual? %>
<%= trailing_visual %>
<% end %>
<% end %>
diff --git a/app/components/primer/alpha/tree_view/node.rb b/app/components/primer/alpha/tree_view/node.rb
index a155c65b85..2df5e1c899 100644
--- a/app/components/primer/alpha/tree_view/node.rb
+++ b/app/components/primer/alpha/tree_view/node.rb
@@ -56,6 +56,7 @@ class Node < Primer::Component
DEFAULT_SELECT_VARIANT = :none
SELECT_VARIANT_OPTIONS = [
+ :single,
:multiple,
DEFAULT_SELECT_VARIANT
].freeze
@@ -128,7 +129,8 @@ def initialize(
@content_arguments, {
data: {
value: value,
- path: @path.to_json
+ path: @path.to_json,
+ select_variant: @select_variant
}
}
)
@@ -169,6 +171,10 @@ def merge_system_arguments!(**other_arguments)
private
def before_render
+ if trailing_visual? && select_variant == :single
+ raise ArgumentError, "Trailing visuals can't be used in combination with single select mode as the icon is reserved."
+ end
+
if leading_action?
@content_arguments[:data] = merge_data(
@content_arguments,
diff --git a/app/components/primer/alpha/tree_view/sub_tree_node.rb b/app/components/primer/alpha/tree_view/sub_tree_node.rb
index b42c54eaa1..3dd622a7da 100644
--- a/app/components/primer/alpha/tree_view/sub_tree_node.rb
+++ b/app/components/primer/alpha/tree_view/sub_tree_node.rb
@@ -155,7 +155,7 @@ def initialize(
node_variant: node_variant
)
- return if @node.select_variant == :none
+ return unless @node.select_variant == :multiple
@node.merge_system_arguments!(
data: {
diff --git a/app/components/primer/alpha/tree_view/tree_view.ts b/app/components/primer/alpha/tree_view/tree_view.ts
index a316e0d8d4..88977adf5a 100644
--- a/app/components/primer/alpha/tree_view/tree_view.ts
+++ b/app/components/primer/alpha/tree_view/tree_view.ts
@@ -1,7 +1,7 @@
import {controller, target} from '@github/catalyst'
-import {TreeViewSubTreeNodeElement} from './tree_view_sub_tree_node_element'
+import {SelectVariant, TreeViewSubTreeNodeElement} from './tree_view_sub_tree_node_element'
import {useRovingTabIndex} from './tree_view_roving_tab_index'
-import type {TreeViewNodeType, TreeViewCheckedValue, TreeViewNodeInfo} from '../../shared_events'
+import type {TreeViewCheckedValue, TreeViewNodeInfo, TreeViewNodeType} from '../../shared_events'
@controller
export class TreeViewElement extends HTMLElement {
@@ -114,6 +114,8 @@ export class TreeViewElement extends HTMLElement {
#handleNodeEvent(node: Element, event: Event) {
if (this.#eventIsCheckboxToggle(event, node)) {
this.#handleCheckboxToggle(event, node)
+ } else if (this.#eventIsSingleSelection(event, node)) {
+ this.handleSingleSelection(event, node)
} else if (this.#eventIsActivation(event)) {
this.#handleNodeActivated(event, node)
} else if (event.type === 'focusin') {
@@ -145,6 +147,48 @@ export class TreeViewElement extends HTMLElement {
}
}
+ #eventIsSingleSelection(event: Event, node: Element) {
+ return event.type === 'click' && this.selectVariant(node) === 'single'
+ }
+
+ handleSingleSelection(event: Event, node: Element) {
+ if (this.getNodeDisabledValue(node)) {
+ event.preventDefault()
+ return
+ }
+
+ // do not emit activation events for buttons and anchors, since it is assumed any activation
+ // behavior for these element types is user- or browser-defined
+ if (!(node instanceof HTMLDivElement)) return
+
+ const path = this.getNodePath(node)
+ const nodeInfo = this.infoFromNode(node, 'true')
+
+ const checkSuccess = this.dispatchEvent(
+ new CustomEvent('treeViewBeforeNodeChecked', {
+ bubbles: true,
+ cancelable: true,
+ detail: [nodeInfo],
+ }),
+ )
+
+ if (!checkSuccess) return
+
+ const currentlyChecked = !this.getNodeCheckedValue(node)
+
+ // disallow unchecking checked item in single-select mode
+ if (!currentlyChecked) {
+ this.checkOnlyAtPath(path)
+ }
+
+ this.dispatchEvent(
+ new CustomEvent('treeViewNodeChecked', {
+ bubbles: true,
+ detail: [nodeInfo],
+ }),
+ )
+ }
+
#handleNodeActivated(event: Event, node: Element) {
if (this.getNodeDisabledValue(node)) {
event.preventDefault()
@@ -199,7 +243,7 @@ export class TreeViewElement extends HTMLElement {
break
}
- if (this.nodeHasCheckBox(node)) {
+ if (this.selectVariant(node) === 'multiple') {
event.preventDefault()
if (this.getNodeCheckedValue(node) === 'true') {
@@ -207,6 +251,10 @@ export class TreeViewElement extends HTMLElement {
} else {
this.setNodeCheckedValue(node, 'true')
}
+ } else if (this.selectVariant(node) === 'single') {
+ event.preventDefault()
+
+ this.checkOnlyAtPath(this.getNodePath(node))
} else if (node instanceof HTMLAnchorElement) {
// simulate click on space
node.click()
@@ -247,6 +295,10 @@ export class TreeViewElement extends HTMLElement {
return this.querySelector('[aria-current=true]')
}
+ get activeNodes() {
+ return document.querySelectorAll('[aria-checked="true"]')
+ }
+
expandAtPath(path: string[]) {
const node = this.subTreeAtPath(path)
if (!node) return
@@ -282,6 +334,14 @@ export class TreeViewElement extends HTMLElement {
this.setNodeCheckedValue(node, 'false')
}
+ checkOnlyAtPath(path: string[]) {
+ for (const el of this.activeNodes) {
+ this.uncheckAtPath(this.getNodePath(el))
+ }
+
+ this.checkAtPath(path)
+ }
+
toggleCheckedAtPath(path: string[]) {
const node = this.nodeAtPath(path)
if (!node) return
@@ -382,6 +442,10 @@ export class TreeViewElement extends HTMLElement {
previousCheckedValue: checkedValue,
}
}
+
+ selectVariant(node: Element): SelectVariant {
+ return (node.getAttribute('data-select-variant') || 'none') as SelectVariant
+ }
}
if (!window.customElements.get('tree-view')) {
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..4316dcdcdc 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
@@ -9,6 +9,8 @@ type LoadingState = 'loading' | 'error' | 'success'
export type SelectStrategy = 'self' | 'descendants' | 'mixed_descendants'
+export type SelectVariant = 'none' | 'single' | 'multiple'
+
@controller
export class TreeViewSubTreeNodeElement extends HTMLElement {
@target node: HTMLElement
@@ -133,6 +135,10 @@ export class TreeViewSubTreeNodeElement extends HTMLElement {
return (this.node.getAttribute('data-select-strategy') || 'descendants') as SelectStrategy
}
+ get selectVariant(): SelectVariant {
+ return (this.node.getAttribute('data-select-variant') || 'none') as SelectVariant
+ }
+
disconnectedCallback() {
this.#abortController.abort()
}
@@ -311,6 +317,9 @@ export class TreeViewSubTreeNodeElement extends HTMLElement {
if (this.#checkboxElement) {
this.toggleChecked()
+ } else if (this.selectVariant === 'single') {
+ // Follow the standard implementation of TreeView and select that item
+ this.treeView.handleSingleSelection(event, node)
} else if (!this.treeView?.nodeHasNativeAction(node)) {
// toggle only if this node isn't eg. an anchor or button
this.toggle()
@@ -342,6 +351,9 @@ export class TreeViewSubTreeNodeElement extends HTMLElement {
event.preventDefault()
this.toggleChecked()
+ } else if (this.selectVariant === 'single') {
+ // Follow the standard implementation of TreeView and select that item
+ this.treeView.handleSingleSelection(event, node)
} else {
if (node instanceof HTMLAnchorElement) {
// simulate click on space for anchors (buttons already handle this natively)
diff --git a/previews/primer/alpha/tree_view_preview.rb b/previews/primer/alpha/tree_view_preview.rb
index 54fbbae100..b4bca3f24d 100644
--- a/previews/primer/alpha/tree_view_preview.rb
+++ b/previews/primer/alpha/tree_view_preview.rb
@@ -9,7 +9,7 @@ class TreeViewPreview < ViewComponent::Preview
# @snapshot interactive
# @param expanded [Boolean] toggle
# @param disabled [Boolean] toggle
- # @param select_variant [Symbol] select [multiple, none]
+ # @param select_variant [Symbol] select [multiple, single, none]
# @param select_strategy [Symbol] select [self, descendants, mixed_descendants]
def default(
expanded: false,
@@ -28,7 +28,7 @@ def default(
# @label Playground
#
# @param expanded [Boolean] toggle
- # @param select_variant [Symbol] select [multiple, none]
+ # @param select_variant [Symbol] select [multiple, single, none]
# @param select_strategy [Symbol] select [self, descendants, mixed_descendants]
def playground(
expanded: false,
@@ -43,6 +43,38 @@ def playground(
})
end
+ # @label Single select
+ #
+ # @snapshot interactive
+ # @param expanded [Boolean] toggle
+ # @param disabled [Boolean] toggle
+ def single_select(
+ expanded: false,
+ disabled: false
+ )
+ render_with_template(locals: {
+ expanded: coerce_bool(expanded),
+ disabled: coerce_bool(disabled),
+ select_variant: :single,
+ })
+ end
+
+ # @label Multi select
+ #
+ # @snapshot interactive
+ # @param expanded [Boolean] toggle
+ # @param disabled [Boolean] toggle
+ def multi_select(
+ expanded: false,
+ disabled: false
+ )
+ render_with_template(locals: {
+ expanded: coerce_bool(expanded),
+ disabled: coerce_bool(disabled),
+ select_variant: :multiple,
+ })
+ end
+
# @label Empty
#
# @snapshot interactive
@@ -94,7 +126,7 @@ def async_alpha(action_menu_expanded: false)
# @param leading_visual_icon [Symbol] octicon
# @param leading_action_icon [Symbol] octicon
# @param trailing_visual_icon [Symbol] octicon
- # @param select_variant [Symbol] select [multiple, none]
+ # @param select_variant [Symbol] select [multiple, single, none]
# @param disabled [Boolean] toggle
def leaf_node_playground(
label: "Leaf node",
@@ -153,9 +185,11 @@ def auto_expansion
# @label Form input
#
+ # @param select_variant [Symbol] select [multiple, single]
# @param expanded [Boolean] toggle
- def form_input(expanded: true)
+ def form_input(select_variant: :multiple, expanded: true)
render_with_template(locals: {
+ select_variant: select_variant.to_sym,
expanded: coerce_bool(expanded)
})
end
diff --git a/previews/primer/alpha/tree_view_preview/default.html.erb b/previews/primer/alpha/tree_view_preview/default.html.erb
index fc1bb3ff69..5dd618fc01 100644
--- a/previews/primer/alpha/tree_view_preview/default.html.erb
+++ b/previews/primer/alpha/tree_view_preview/default.html.erb
@@ -6,7 +6,7 @@
<% icons.with_collapsed_icon(icon: :"file-directory-fill", color: :accent) %>
<% end %>
- <% sub_tree.with_trailing_visual_icon(icon: :"diff-modified") %>
+ <% sub_tree.with_trailing_visual_icon(icon: :"diff-modified") unless select_variant == :single %>
<% sub_tree.with_leaf(label: "button.rb", disabled: disabled, select_variant: select_variant) do |item| %>
<% item.with_leading_visual_icon(icon: :file) %>
diff --git a/previews/primer/alpha/tree_view_preview/form_input.html.erb b/previews/primer/alpha/tree_view_preview/form_input.html.erb
index 9d3ca62ff7..805269649e 100644
--- a/previews/primer/alpha/tree_view_preview/form_input.html.erb
+++ b/previews/primer/alpha/tree_view_preview/form_input.html.erb
@@ -1,12 +1,12 @@
<%= form_with(url: primer_view_components.generic_form_submission_path(format: :json)) do |f| %>
<%= render(Primer::Alpha::Stack.new) do %>
<%= render(Primer::Alpha::TreeView.new(form_arguments: { builder: f, name: "folder_structure" })) do |tree| %>
- <% tree.with_sub_tree(label: "src", expanded: expanded, select_variant: :multiple, value: 0) do |sub_tree| %>
- <% sub_tree.with_leaf(label: "button.rb", select_variant: :multiple, value: 1) %>
- <% sub_tree.with_leaf(label: "icon_button.rb", current: true, select_variant: :multiple, value: 2) %>
+ <% tree.with_sub_tree(label: "src", expanded: expanded, select_variant: select_variant, value: 0) do |sub_tree| %>
+ <% sub_tree.with_leaf(label: "button.rb", select_variant: select_variant, value: 1) %>
+ <% sub_tree.with_leaf(label: "icon_button.rb", current: true, select_variant: select_variant, value: 2) %>
<% end %>
- <% tree.with_leaf(label: "action_menu.rb", select_variant: :multiple, value: 3) %>
+ <% tree.with_leaf(label: "action_menu.rb", select_variant: select_variant, value: 3) %>
<% end %>
<%= render(Primer::Alpha::SubmitButton.new(name: :submit, label: "Submit")) %>
diff --git a/previews/primer/alpha/tree_view_preview/multi_select.html.erb b/previews/primer/alpha/tree_view_preview/multi_select.html.erb
new file mode 100644
index 0000000000..74bc3f57ad
--- /dev/null
+++ b/previews/primer/alpha/tree_view_preview/multi_select.html.erb
@@ -0,0 +1,10 @@
+
+ <%= render(Primer::Alpha::TreeView.new) do |tree_view| %>
+ <% tree_view.with_sub_tree(label: "src", expanded: expanded, disabled: disabled, select_variant: select_variant) do |sub_tree| %>
+ <% sub_tree.with_leaf(label: "button.rb", disabled: disabled, select_variant: select_variant) %>
+ <% sub_tree.with_leaf(label: "icon_button.rb", current: true, disabled: disabled, select_variant: select_variant) %>
+ <% end %>
+
+ <% tree_view.with_leaf(label: "action_menu.rb", disabled: disabled, select_variant: select_variant) %>
+ <% end %>
+
diff --git a/previews/primer/alpha/tree_view_preview/single_select.html.erb b/previews/primer/alpha/tree_view_preview/single_select.html.erb
new file mode 100644
index 0000000000..74bc3f57ad
--- /dev/null
+++ b/previews/primer/alpha/tree_view_preview/single_select.html.erb
@@ -0,0 +1,10 @@
+
+ <%= render(Primer::Alpha::TreeView.new) do |tree_view| %>
+ <% tree_view.with_sub_tree(label: "src", expanded: expanded, disabled: disabled, select_variant: select_variant) do |sub_tree| %>
+ <% sub_tree.with_leaf(label: "button.rb", disabled: disabled, select_variant: select_variant) %>
+ <% sub_tree.with_leaf(label: "icon_button.rb", current: true, disabled: disabled, select_variant: select_variant) %>
+ <% end %>
+
+ <% tree_view.with_leaf(label: "action_menu.rb", disabled: disabled, select_variant: select_variant) %>
+ <% end %>
+
diff --git a/test/components/primer/alpha/tree_view_test.rb b/test/components/primer/alpha/tree_view_test.rb
index 2cb9ea3da4..423db56e76 100644
--- a/test/components/primer/alpha/tree_view_test.rb
+++ b/test/components/primer/alpha/tree_view_test.rb
@@ -193,6 +193,19 @@ def test_falls_back_to_div_variant_when_invalid_variant_given
assert_selector "div[role=treeitem]", text: "Foobar"
end
+
+ def test_disallows_trailing_visuals_when_single_select_variant_is_used
+ error = assert_raises(ArgumentError) do
+ render_inline(Primer::Alpha::TreeView.new) do |tree|
+ tree.with_sub_tree(label: "src", select_variant: :single) do |sub_tree|
+ sub_tree.with_trailing_visual_icon(icon: :"diff-modified")
+ sub_tree.with_leaf(label: "button.rb")
+ end
+ end
+ end
+
+ assert_equal error.message, "Trailing visuals can't be used in combination with single select mode as the icon is reserved."
+ end
end
end
end
diff --git a/test/system/alpha/tree_view_test.rb b/test/system/alpha/tree_view_test.rb
index e1002c36e6..373eae24fc 100644
--- a/test/system/alpha/tree_view_test.rb
+++ b/test/system/alpha/tree_view_test.rb
@@ -607,6 +607,51 @@ def test_fires_activation_event
assert_equal details["previousCheckedValue"], "false"
end
+ def test_fires_event_before_checking_single_variant
+ visit_preview(:default, select_variant: :single)
+
+ details = capture_event("treeViewBeforeNodeChecked") do
+ activate_at_path("src")
+ end
+
+ assert_equal details.size, 1
+
+ assert details[0]["node"]
+ assert_equal details[0]["type"], "sub-tree"
+ assert_equal details[0]["path"], ["src"]
+ assert_equal details[0]["checkedValue"], "true"
+ assert_equal details[0]["previousCheckedValue"], "false"
+ end
+
+ def test_canceling_check_event_prevents_checking_for_single_variant
+ visit_preview(:default, select_variant: :single)
+
+ refute_path_checked "src"
+
+ capture_event("treeViewBeforeNodeChecked", cancel: true) do
+ activate_at_path("src")
+ end
+
+ # src should still not be checked
+ refute_path_checked "src"
+ end
+
+ def test_fires_check_event_after_single_variant
+ visit_preview(:default, select_variant: :single)
+
+ details = capture_event("treeViewNodeChecked") do
+ activate_at_path("src")
+ end
+
+ assert_equal details.size, 1
+
+ assert details[0]["node"]
+ assert_equal details[0]["type"], "sub-tree"
+ assert_equal details[0]["path"], ["src"]
+ assert_equal details[0]["checkedValue"], "true"
+ assert_equal details[0]["previousCheckedValue"], "false"
+ end
+
def test_fires_event_before_checking
visit_preview(:default, select_variant: :multiple)
@@ -786,7 +831,6 @@ def test_self_select_strategy_checking_sub_tree_does_not_check_children
assert_path_checked "primer", "alpha", "action_bar"
end
-
def test_form_submission
visit_preview(:form_input, expanded: true, route_format: :json)
@@ -799,5 +843,18 @@ def test_form_submission
assert_equal "{\"path\":[\"action_menu.rb\"],\"value\":\"3\"}", response.dig("form_params", "folder_structure", 0)
end
+
+ def test_form_submission_with_single_select_variant
+ visit_preview(:form_input, expanded: true, select_variant: :single, route_format: :json)
+
+ activate_at_path("action_menu.rb")
+
+ find("button[type=submit]").click
+
+ # for some reason the JSON response is wrapped in HTML, I have no idea why
+ response = JSON.parse(find("pre").text)
+
+ assert_equal "{\"path\":[\"action_menu.rb\"],\"value\":\"3\"}", response.dig("form_params", "folder_structure", 0)
+ end
end
end