openproject-primer_view_components 0.66.1 → 0.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/app/assets/javascripts/components/primer/open_project/tree_view/tree_view.d.ts +2 -0
  4. data/app/assets/javascripts/components/primer/open_project/tree_view/tree_view_sub_tree_node_element.d.ts +4 -2
  5. data/app/assets/javascripts/primer_view_components.js +1 -1
  6. data/app/assets/javascripts/primer_view_components.js.map +1 -1
  7. data/app/assets/styles/primer_view_components.css +1 -1
  8. data/app/assets/styles/primer_view_components.css.map +1 -1
  9. data/app/components/primer/open_project/border_box/collapsible_header.rb +3 -0
  10. data/app/components/primer/open_project/collapsible_section.rb +7 -1
  11. data/app/components/primer/open_project/tree_view/node.html.erb +2 -2
  12. data/app/components/primer/open_project/tree_view/node.rb +49 -26
  13. data/app/components/primer/open_project/tree_view/skeleton_loader.html.erb +1 -1
  14. data/app/components/primer/open_project/tree_view/spinner_loader.html.erb +2 -2
  15. data/app/components/primer/open_project/tree_view/sub_tree.html.erb +1 -1
  16. data/app/components/primer/open_project/tree_view/sub_tree.rb +8 -1
  17. data/app/components/primer/open_project/tree_view/sub_tree_node.rb +9 -3
  18. data/app/components/primer/open_project/tree_view/tree_view.d.ts +2 -0
  19. data/app/components/primer/open_project/tree_view/tree_view.js +29 -9
  20. data/app/components/primer/open_project/tree_view/tree_view.ts +31 -10
  21. data/app/components/primer/open_project/tree_view/tree_view_roving_tab_index.js +11 -7
  22. data/app/components/primer/open_project/tree_view/tree_view_roving_tab_index.ts +13 -8
  23. data/app/components/primer/open_project/tree_view/tree_view_sub_tree_node_element.d.ts +4 -2
  24. data/app/components/primer/open_project/tree_view/tree_view_sub_tree_node_element.js +60 -30
  25. data/app/components/primer/open_project/tree_view/tree_view_sub_tree_node_element.ts +66 -33
  26. data/app/components/primer/open_project/tree_view.css +1 -1
  27. data/app/components/primer/open_project/tree_view.css.json +9 -6
  28. data/app/components/primer/open_project/tree_view.css.map +1 -1
  29. data/app/components/primer/open_project/tree_view.pcss +53 -38
  30. data/app/components/primer/open_project/tree_view.rb +88 -24
  31. data/lib/primer/view_components/version.rb +2 -2
  32. data/previews/primer/open_project/border_box/collapsible_header_preview/playground.html.erb +1 -1
  33. data/previews/primer/open_project/tree_view_preview/buttons.html.erb +10 -0
  34. data/previews/primer/open_project/tree_view_preview/links.html.erb +17 -0
  35. data/previews/primer/open_project/tree_view_preview.rb +29 -3
  36. data/static/arguments.json +38 -2
  37. data/static/constants.json +17 -0
  38. data/static/info_arch.json +95 -3
  39. data/static/previews.json +26 -0
  40. metadata +4 -2
@@ -12,6 +12,8 @@ module Primer
12
12
  #
13
13
  # @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
14
14
  renders_one :title, lambda { |**system_arguments, &block|
15
+ raise ArgumentError, "Title must be a string" unless block.call.is_a?(String)
16
+
15
17
  system_arguments[:mr] ||= 2
16
18
 
17
19
  Primer::Beta::Text.new(**system_arguments, &block)
@@ -31,6 +33,7 @@ module Primer
31
33
  #
32
34
  # @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
33
35
  renders_one :description, lambda { |**system_arguments, &block|
36
+ raise ArgumentError, "Description must be a string" unless block.call.is_a?(String)
34
37
  system_arguments[:color] ||= :subtle
35
38
  system_arguments[:hidden] = @collapsed
36
39
 
@@ -15,6 +15,8 @@ module Primer
15
15
  # @param tag [Symbol] Customize the element type of the rendered title container.
16
16
  # @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
17
17
  renders_one :title, lambda { |tag: TITLE_TAG_FALLBACK, **system_arguments, &block|
18
+ raise ArgumentError, "Title must be a string" unless block.call.is_a?(String)
19
+
18
20
  system_arguments[:tag] = fetch_or_fallback(TITLE_TAG_OPTIONS, tag, TITLE_TAG_FALLBACK)
19
21
  system_arguments[:font_size] ||= 3
20
22
  system_arguments[:mr] ||= 2
@@ -26,6 +28,8 @@ module Primer
26
28
  #
27
29
  # @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
28
30
  renders_one :caption, lambda { |**system_arguments, &block|
31
+ raise ArgumentError, "Caption must be a string" unless block.call.is_a?(String)
32
+
29
33
  system_arguments[:color] ||= :subtle
30
34
  system_arguments[:mr] ||= 2
31
35
  system_arguments[:display] ||= [:none, :block]
@@ -36,7 +40,9 @@ module Primer
36
40
  # Optional right-side content
37
41
  #
38
42
  # @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
39
- renders_one :additional_information, lambda { |**system_arguments|
43
+ renders_one :additional_information, lambda { |**system_arguments, &block|
44
+ raise ArgumentError, "The additional information must be a string" unless block.call.is_a?(String)
45
+
40
46
  Primer::BaseComponent.new(tag: :div, **system_arguments)
41
47
  }
42
48
 
@@ -13,7 +13,7 @@
13
13
  <% if leading_action? %>
14
14
  <%= leading_action %>
15
15
  <% end %>
16
- <div id="<%= content_id %>" class="TreeViewItemContent">
16
+ <%= render(Primer::BaseComponent.new(**@content_arguments)) do %>
17
17
  <% if @select_variant == :multiple %>
18
18
  <div aria-hidden="true" class="TreeViewItemCheckbox" tabindex="-1">
19
19
  <div class="FormControl-checkbox"></div>
@@ -26,7 +26,7 @@
26
26
  <% if trailing_visual? %>
27
27
  <%= trailing_visual %>
28
28
  <% end %>
29
- </div>
29
+ <% end %>
30
30
  </div>
31
31
  <%= content %>
32
32
  <% end %>
@@ -8,6 +8,10 @@ module Primer
8
8
  # This component is part of the <%= link_to_component(Primer::OpenProject::TreeView) %> component and should
9
9
  # not be used directly.
10
10
  class Node < Primer::Component
11
+ DEFAULT_NODE_VARIANT = Primer::OpenProject::TreeView::DEFAULT_NODE_VARIANT
12
+ NODE_VARIANT_TAG_MAP = { DEFAULT_NODE_VARIANT => :div, :button => :button, :anchor => :a }.freeze
13
+ NODE_VARIANT_TAG_OPTIONS = NODE_VARIANT_TAG_MAP.keys.freeze
14
+
11
15
  # Generic leading action slot
12
16
  renders_one :leading_action
13
17
 
@@ -39,6 +43,11 @@ module Primer
39
43
  # @return [Symbol]
40
44
  attr_reader :select_variant
41
45
 
46
+ # This node's variant, eg. `:button`, `:div`, etc.
47
+ #
48
+ # @return [Symbol]
49
+ attr_reader :node_variant
50
+
42
51
  DEFAULT_SELECT_VARIANT = :none
43
52
  SELECT_VARIANT_OPTIONS = [
44
53
  :multiple,
@@ -53,34 +62,47 @@ module Primer
53
62
  ]
54
63
 
55
64
  # @param path [Array<String>] The node's "path," i.e. this node's label and the labels of all its ancestors. This node should be reachable by traversing the tree following this path.
65
+ # @param node_variant [Symbol] The node variant to use for the node's content, i.e. the `:button` or `:div`. <%= one_of(Primer::OpenProject::TreeView::NODE_VARIANT_OPTIONS) %>
66
+ # @param href [String] The URL to use as the `href` attribute for this node. If set to a truthy value, the `tag:` parameter is ignored and assumed to be `:a`.
56
67
  # @param current [Boolean] Whether or not this node is the current node. The current node is styled differently than regular nodes and is the first element that receives focus when tabbing to the `TreeView` component.
57
68
  # @param select_variant [Symbol] Controls the type of checkbox that appears. <%= one_of(Primer::OpenProject::TreeView::Node::SELECT_VARIANT_OPTIONS) %>
58
69
  # @param checked [Boolean | String] The checked state of the node's checkbox. <%= one_of(Primer::OpenProject::TreeView::Node::CHECKED_STATES) %>
59
- # @param system_arguments [Hash] The arguments accepted by <%= link_to_component(Primer::Alpha::ActionList) %>.
70
+ # @param content_arguments [Hash] Arguments attached to the node's content, i.e the `<button>` or `<a>` element. <%= link_to_system_arguments_docs %>
60
71
  def initialize(
61
72
  path:,
73
+ node_variant:,
74
+ href: nil,
62
75
  current: false,
63
76
  select_variant: DEFAULT_SELECT_VARIANT,
64
77
  checked: DEFAULT_CHECKED_STATE,
65
- **system_arguments
78
+ **content_arguments
66
79
  )
67
- @system_arguments = deny_tag_argument(**system_arguments)
80
+ @system_arguments = {
81
+ tag: :li,
82
+ role: :none,
83
+ classes: "TreeViewItem"
84
+ }
85
+
86
+ @content_arguments = content_arguments
68
87
 
69
88
  @path = path
70
89
  @current = current
71
90
  @select_variant = fetch_or_fallback(SELECT_VARIANT_OPTIONS, select_variant, DEFAULT_SELECT_VARIANT)
72
91
  @checked = fetch_or_fallback(CHECKED_STATES, checked, DEFAULT_CHECKED_STATE)
73
-
74
- @system_arguments[:tag] = :li
75
- @system_arguments[:role] = :treeitem
76
- @system_arguments[:tabindex] = current? ? 0 : -1
77
- @system_arguments[:classes] = class_names(
78
- @system_arguments.delete(:classes),
79
- "TreeViewItem"
92
+ @node_variant = fetch_or_fallback(NODE_VARIANT_TAG_OPTIONS, node_variant, DEFAULT_NODE_VARIANT)
93
+
94
+ @content_arguments[:tag] = NODE_VARIANT_TAG_MAP[@node_variant]
95
+ @content_arguments[:href] = href if href
96
+ @content_arguments[:id] = content_id
97
+ @content_arguments[:role] = :treeitem
98
+ @content_arguments[:tabindex] = current? ? 0 : -1
99
+ @content_arguments[:classes] = class_names(
100
+ @content_arguments.delete(:classes),
101
+ "TreeViewItemContent"
80
102
  )
81
103
 
82
- @system_arguments[:aria] = merge_aria(
83
- @system_arguments, {
104
+ @content_arguments[:aria] = merge_aria(
105
+ @content_arguments, {
84
106
  aria: {
85
107
  level: level,
86
108
  selected: false,
@@ -90,15 +112,15 @@ module Primer
90
112
  }
91
113
  )
92
114
 
93
- @system_arguments[:data] = merge_data(
94
- @system_arguments,
115
+ @content_arguments[:data] = merge_data(
116
+ @content_arguments,
95
117
  { data: { path: @path.to_json } }
96
118
  )
97
119
 
98
120
  return unless current?
99
121
 
100
- @system_arguments[:aria] = merge_aria(
101
- @system_arguments,
122
+ @content_arguments[:aria] = merge_aria(
123
+ @content_arguments,
102
124
  { aria: { current: true } }
103
125
  )
104
126
  end
@@ -115,31 +137,32 @@ module Primer
115
137
  #
116
138
  # @param other_arguments [Hash] The other hash of system arguments to merge into the current one.
117
139
  def merge_system_arguments!(**other_arguments)
118
- @system_arguments[:aria] = merge_aria(
119
- @system_arguments,
140
+ @content_arguments[:aria] = merge_aria(
141
+ @content_arguments,
120
142
  other_arguments
121
143
  )
122
144
 
123
- @system_arguments[:data] = merge_data(
124
- @system_arguments,
145
+ @content_arguments[:data] = merge_data(
146
+ @content_arguments,
125
147
  other_arguments
126
148
  )
127
149
 
128
- @system_arguments.merge!(**other_arguments)
150
+ @content_arguments.merge!(**other_arguments)
129
151
  end
130
152
 
131
153
  private
132
154
 
133
155
  def before_render
134
- if leading_visual?
135
- end
136
-
137
156
  if leading_action?
138
- @system_arguments[:data] = merge_data(
139
- @system_arguments,
157
+ @content_arguments[:data] = merge_data(
158
+ @content_arguments,
140
159
  { data: { "has-leading-action": true } }
141
160
  )
142
161
  end
162
+
163
+ if select_variant != :none && node_variant != :div
164
+ raise ArgumentError, "TreeView nodes do not support select variants for tags other than :div."
165
+ end
143
166
  end
144
167
 
145
168
  def content_id
@@ -4,7 +4,7 @@
4
4
 
5
5
  <%= render(Primer::BaseComponent.new(tag: :"tree-view-include-fragment", src: @src, loading: :lazy, data: { target: "tree-view-sub-tree-node.subTree tree-view-sub-tree-node.includeFragment", path: @container.path.to_json }, hidden: @container.expanded?, accept: "text/fragment+html")) do %>
6
6
  <%= render(@container) do %>
7
- <%= render(Primer::OpenProject::TreeView::Node.new(path: [*@container.path, :loader])) do |node| %>
7
+ <%= render(Primer::OpenProject::TreeView::Node.new(path: [*@container.path, :loader], node_variant: :div)) do |node| %>
8
8
  <% node.with_text_content do %>
9
9
  <div data-target="tree-view-sub-tree-node.loadingIndicator">
10
10
  <% @count.times do %>
@@ -4,14 +4,14 @@
4
4
 
5
5
  <%= render(Primer::BaseComponent.new(tag: :"tree-view-include-fragment", src: @src, loading: :lazy, data: { target: "tree-view-sub-tree-node.subTree tree-view-sub-tree-node.includeFragment", path: @container.path.to_json }, hidden: @container.expanded?, accept: "text/fragment+html")) do %>
6
6
  <%= render(@container) do %>
7
- <%= render(Primer::OpenProject::TreeView::Node.new(path: [*@container.path, :loader], data: { target: "tree-view-sub-tree-node.loadingIndicator" })) do |node| %>
7
+ <%= render(Primer::OpenProject::TreeView::Node.new(path: [*@container.path, :loader], data: { target: "tree-view-sub-tree-node.loadingIndicator" }, node_variant: :div)) do |node| %>
8
8
  <% node.with_text_content { "Loading..." } %>
9
9
  <% node.with_leading_visual do %>
10
10
  <%= render(Primer::Beta::Spinner.new(size: :small, wrapper_arguments: { classes: "TreeViewItemVisual" })) %>
11
11
  <% end %>
12
12
  <% end %>
13
13
 
14
- <%= render(Primer::OpenProject::TreeView::Node.new(path: [*@container.path, :failure_msg], data: { target: "tree-view-sub-tree-node.loadingFailureMessage" }, hidden: true)) do |node| %>
14
+ <%= render(Primer::OpenProject::TreeView::Node.new(path: [*@container.path, :failure_msg], data: { target: "tree-view-sub-tree-node.loadingFailureMessage" }, hidden: true, node_variant: :div)) do |node| %>
15
15
  <% node.with_text_content do %>
16
16
  <%= loading_failure_message %>
17
17
  <% end %>
@@ -7,7 +7,7 @@
7
7
 
8
8
  <%= render(@container) do %>
9
9
  <% if nodes.empty? %>
10
- <%= render(Primer::OpenProject::TreeView::Node.new(path: path, data: { "no-items": true })) do |node| %>
10
+ <%= render(Primer::OpenProject::TreeView::Node.new(path: path, data: { "no-items": true }, node_variant: :div)) do |node| %>
11
11
  <% node.with_text_content do %>
12
12
  <%= no_items_message %>
13
13
  <% end %>
@@ -29,6 +29,7 @@ module Primer
29
29
  renders: lambda { |component_klass: LeafNode, label:, **system_arguments|
30
30
  component_klass.new(
31
31
  **system_arguments,
32
+ node_variant: node_variant,
32
33
  path: [*path, label],
33
34
  label: label
34
35
  )
@@ -41,6 +42,7 @@ module Primer
41
42
  renders: lambda { |component_klass: SubTreeNode, label:, **system_arguments|
42
43
  component_klass.new(
43
44
  **system_arguments,
45
+ node_variant: node_variant,
44
46
  path: [*path, label],
45
47
  label: label
46
48
  )
@@ -87,8 +89,13 @@ module Primer
87
89
 
88
90
  delegate :path, :expanded?, to: :@container
89
91
 
92
+ attr_reader :node_variant
93
+
94
+ # @param node_variant [Symbol] The variant to use for this node. <%= one_of(Primer::OpenProject::TreeView::NODE_VARIANT_OPTIONS) %>
90
95
  # @param system_arguments [Hash] The arguments accepted by <%= link_to_component(Primer::OpenProject::TreeView::SubTreeContainer) %>.
91
- def initialize(**system_arguments)
96
+ def initialize(node_variant:, **system_arguments)
97
+ @node_variant = node_variant
98
+
92
99
  system_arguments[:data] = merge_data(
93
100
  system_arguments,
94
101
  { data: { target: "tree-view-sub-tree-node.subTree" } }
@@ -102,15 +102,16 @@ module Primer
102
102
  }
103
103
  }
104
104
 
105
- delegate :with_leaf, :with_sub_tree, :with_loading_spinner, :with_loading_skeleton, to: :@sub_tree
105
+ delegate :with_leaf, :with_sub_tree, :with_loading_spinner, :with_loading_skeleton, :nodes, to: :@sub_tree
106
106
  delegate :current?, :merge_system_arguments!, to: :@node
107
107
 
108
108
  # @param label [String] The node's label, i.e. it's textual content.
109
109
  # @param path [Array<String>] The node's "path," i.e. this node's label and the labels of all its ancestors. This node should be reachable by traversing the tree following this path.
110
+ # @param node_variant [Symbol] The variant to use for this node. <%= one_of(Primer::OpenProject::TreeView::NODE_VARIANT_OPTIONS) %>
110
111
  # @param expanded [Boolean] Whether or not this sub-tree should be rendered expanded.
111
112
  # @param select_strategy [Symbol] What should happen when this sub-tree node is checked. <%= one_of(Primer::OpenProject::TreeView::SubTreeNode::SELECT_STRATEGIES) %>
112
113
  # @param system_arguments [Hash] The arguments accepted by <%= link_to_component(Primer::OpenProject::TreeView::Node) %>.
113
- def initialize(label:, path:, expanded: false, select_strategy: DEFAULT_SELECT_STRATEGY, **system_arguments)
114
+ def initialize(label:, path:, node_variant:, expanded: false, select_strategy: DEFAULT_SELECT_STRATEGY, **system_arguments)
114
115
  @label = label
115
116
  @system_arguments = system_arguments
116
117
  @select_strategy = fetch_or_fallback(SELECT_STRATEGIES, select_strategy, DEFAULT_SELECT_STRATEGY)
@@ -134,10 +135,15 @@ module Primer
134
135
  @sub_tree = SubTree.new(
135
136
  expanded: expanded,
136
137
  path: path,
138
+ node_variant: node_variant,
137
139
  **sub_tree_arguments
138
140
  )
139
141
 
140
- @node = Primer::OpenProject::TreeView::Node.new(**@system_arguments, path: @sub_tree.path)
142
+ @node = Primer::OpenProject::TreeView::Node.new(
143
+ **@system_arguments,
144
+ path: @sub_tree.path,
145
+ node_variant: node_variant
146
+ )
141
147
 
142
148
  return if @node.select_variant == :none
143
149
 
@@ -20,6 +20,8 @@ export declare class TreeViewElement extends HTMLElement {
20
20
  subTreeAtPath(path: string[]): TreeViewSubTreeNodeElement | null;
21
21
  leafAtPath(path: string[]): HTMLLIElement | null;
22
22
  getNodeCheckedValue(node: Element): TreeViewCheckedValue;
23
+ nodeHasCheckBox(node: Element): boolean;
24
+ nodeHasNativeAction(node: Element): boolean;
23
25
  infoFromNode(node: Element, newCheckedValue?: TreeViewCheckedValue): TreeViewNodeInfo | null;
24
26
  }
25
27
  declare global {
@@ -126,6 +126,12 @@ let TreeViewElement = class TreeViewElement extends HTMLElement {
126
126
  getNodeCheckedValue(node) {
127
127
  return (node.getAttribute('aria-checked') || 'false');
128
128
  }
129
+ nodeHasCheckBox(node) {
130
+ return node.querySelector('.TreeViewItemCheckbox') !== null;
131
+ }
132
+ nodeHasNativeAction(node) {
133
+ return node instanceof HTMLAnchorElement || node instanceof HTMLButtonElement;
134
+ }
129
135
  // PRIVATE API METHOD
130
136
  //
131
137
  // This would normally be marked private, but it's called by TreeViewSubTreeNodes
@@ -161,7 +167,7 @@ _TreeViewElement_nodeForEvent = function _TreeViewElement_nodeForEvent(event) {
161
167
  return node;
162
168
  };
163
169
  _TreeViewElement_handleNodeEvent = function _TreeViewElement_handleNodeEvent(node, event) {
164
- if (__classPrivateFieldGet(this, _TreeViewElement_instances, "m", _TreeViewElement_eventIsCheckboxToggle).call(this, event)) {
170
+ if (__classPrivateFieldGet(this, _TreeViewElement_instances, "m", _TreeViewElement_eventIsCheckboxToggle).call(this, event, node)) {
165
171
  __classPrivateFieldGet(this, _TreeViewElement_instances, "m", _TreeViewElement_handleCheckboxToggle).call(this, node);
166
172
  }
167
173
  else if (__classPrivateFieldGet(this, _TreeViewElement_instances, "m", _TreeViewElement_eventIsActivation).call(this, event)) {
@@ -174,8 +180,8 @@ _TreeViewElement_handleNodeEvent = function _TreeViewElement_handleNodeEvent(nod
174
180
  __classPrivateFieldGet(this, _TreeViewElement_instances, "m", _TreeViewElement_handleNodeKeyboardEvent).call(this, event, node);
175
181
  }
176
182
  };
177
- _TreeViewElement_eventIsCheckboxToggle = function _TreeViewElement_eventIsCheckboxToggle(event) {
178
- return event.type === 'click' && event.target.closest('.TreeViewItemCheckbox') !== null;
183
+ _TreeViewElement_eventIsCheckboxToggle = function _TreeViewElement_eventIsCheckboxToggle(event, node) {
184
+ return event.type === 'click' && this.nodeHasCheckBox(node);
179
185
  };
180
186
  _TreeViewElement_handleCheckboxToggle = function _TreeViewElement_handleCheckboxToggle(node) {
181
187
  // only handle checking of leaf nodes
@@ -190,6 +196,10 @@ _TreeViewElement_handleCheckboxToggle = function _TreeViewElement_handleCheckbox
190
196
  }
191
197
  };
192
198
  _TreeViewElement_handleNodeActivated = function _TreeViewElement_handleNodeActivated(node) {
199
+ // do not emit activation events for buttons and anchors, since it is assumed any activation
200
+ // behavior for these element types is user- or browser-defined
201
+ if (!(node instanceof HTMLDivElement))
202
+ return;
193
203
  const path = this.getNodePath(node);
194
204
  const activationSuccess = this.dispatchEvent(new CustomEvent('treeViewBeforeNodeActivated', {
195
205
  bubbles: true,
@@ -198,7 +208,10 @@ _TreeViewElement_handleNodeActivated = function _TreeViewElement_handleNodeActiv
198
208
  }));
199
209
  if (!activationSuccess)
200
210
  return;
201
- this.toggleAtPath(path);
211
+ // navigate or trigger button, don't toggle
212
+ if (!this.nodeHasNativeAction(node)) {
213
+ this.toggleAtPath(path);
214
+ }
202
215
  this.dispatchEvent(new CustomEvent('treeViewNodeActivated', {
203
216
  bubbles: true,
204
217
  detail: this.infoFromNode(node),
@@ -215,12 +228,19 @@ _TreeViewElement_handleNodeKeyboardEvent = function _TreeViewElement_handleNodeK
215
228
  }
216
229
  switch (event.key) {
217
230
  case ' ':
218
- event.preventDefault();
219
- if (this.getNodeCheckedValue(node) === 'true') {
220
- __classPrivateFieldGet(this, _TreeViewElement_instances, "m", _TreeViewElement_setNodeCheckedValue).call(this, node, 'false');
231
+ case 'Enter':
232
+ if (this.nodeHasCheckBox(node)) {
233
+ event.preventDefault();
234
+ if (this.getNodeCheckedValue(node) === 'true') {
235
+ __classPrivateFieldGet(this, _TreeViewElement_instances, "m", _TreeViewElement_setNodeCheckedValue).call(this, node, 'false');
236
+ }
237
+ else {
238
+ __classPrivateFieldGet(this, _TreeViewElement_instances, "m", _TreeViewElement_setNodeCheckedValue).call(this, node, 'true');
239
+ }
221
240
  }
222
- else {
223
- __classPrivateFieldGet(this, _TreeViewElement_instances, "m", _TreeViewElement_setNodeCheckedValue).call(this, node, 'true');
241
+ else if (node instanceof HTMLAnchorElement) {
242
+ // simulate click on space
243
+ node.click();
224
244
  }
225
245
  break;
226
246
  }
@@ -44,7 +44,7 @@ export class TreeViewElement extends HTMLElement {
44
44
  }
45
45
 
46
46
  #handleNodeEvent(node: Element, event: Event) {
47
- if (this.#eventIsCheckboxToggle(event)) {
47
+ if (this.#eventIsCheckboxToggle(event, node)) {
48
48
  this.#handleCheckboxToggle(node)
49
49
  } else if (this.#eventIsActivation(event)) {
50
50
  this.#handleNodeActivated(node)
@@ -55,8 +55,8 @@ export class TreeViewElement extends HTMLElement {
55
55
  }
56
56
  }
57
57
 
58
- #eventIsCheckboxToggle(event: Event) {
59
- return event.type === 'click' && (event.target as HTMLElement).closest('.TreeViewItemCheckbox') !== null
58
+ #eventIsCheckboxToggle(event: Event, node: Element) {
59
+ return event.type === 'click' && this.nodeHasCheckBox(node)
60
60
  }
61
61
 
62
62
  #handleCheckboxToggle(node: Element) {
@@ -72,6 +72,10 @@ export class TreeViewElement extends HTMLElement {
72
72
  }
73
73
 
74
74
  #handleNodeActivated(node: Element) {
75
+ // do not emit activation events for buttons and anchors, since it is assumed any activation
76
+ // behavior for these element types is user- or browser-defined
77
+ if (!(node instanceof HTMLDivElement)) return
78
+
75
79
  const path = this.getNodePath(node)
76
80
 
77
81
  const activationSuccess = this.dispatchEvent(
@@ -84,7 +88,10 @@ export class TreeViewElement extends HTMLElement {
84
88
 
85
89
  if (!activationSuccess) return
86
90
 
87
- this.toggleAtPath(path)
91
+ // navigate or trigger button, don't toggle
92
+ if (!this.nodeHasNativeAction(node)) {
93
+ this.toggleAtPath(path)
94
+ }
88
95
 
89
96
  this.dispatchEvent(
90
97
  new CustomEvent('treeViewNodeActivated', {
@@ -107,12 +114,18 @@ export class TreeViewElement extends HTMLElement {
107
114
 
108
115
  switch (event.key) {
109
116
  case ' ':
110
- event.preventDefault()
111
-
112
- if (this.getNodeCheckedValue(node) === 'true') {
113
- this.#setNodeCheckedValue(node, 'false')
114
- } else {
115
- this.#setNodeCheckedValue(node, 'true')
117
+ case 'Enter':
118
+ if (this.nodeHasCheckBox(node)) {
119
+ event.preventDefault()
120
+
121
+ if (this.getNodeCheckedValue(node) === 'true') {
122
+ this.#setNodeCheckedValue(node, 'false')
123
+ } else {
124
+ this.#setNodeCheckedValue(node, 'true')
125
+ }
126
+ } else if (node instanceof HTMLAnchorElement) {
127
+ // simulate click on space
128
+ node.click()
116
129
  }
117
130
 
118
131
  break
@@ -225,6 +238,14 @@ export class TreeViewElement extends HTMLElement {
225
238
  return (node.getAttribute('aria-checked') || 'false') as TreeViewCheckedValue
226
239
  }
227
240
 
241
+ nodeHasCheckBox(node: Element): boolean {
242
+ return node.querySelector('.TreeViewItemCheckbox') !== null
243
+ }
244
+
245
+ nodeHasNativeAction(node: Element): boolean {
246
+ return node instanceof HTMLAnchorElement || node instanceof HTMLButtonElement
247
+ }
248
+
228
249
  // PRIVATE API METHOD
229
250
  //
230
251
  // This would normally be marked private, but it's called by TreeViewSubTreeNodes
@@ -22,12 +22,6 @@ export function useRovingTabIndex(containerEl) {
22
22
  return getNextFocusableElement(from, event) ?? from;
23
23
  },
24
24
  focusInStrategy: () => {
25
- // Don't try to execute the focusInStrategy if focus is coming from a click.
26
- // The clicked row will receive focus correctly by default.
27
- // If a chevron is clicked, setting the focus through the focuszone will prevent its toggle.
28
- // if (mouseDownRef.current) {
29
- // return undefined
30
- // }
31
25
  let currentItem = containerEl.querySelector('[aria-current]');
32
26
  currentItem = currentItem?.checkVisibility() ? currentItem : null;
33
27
  const firstItem = containerEl.querySelector('[role="treeitem"]');
@@ -110,11 +104,21 @@ function getVisibleElement(element, direction) {
110
104
  }
111
105
  let next = direction === 'next' ? walker.nextNode() : walker.previousNode();
112
106
  // If next element is nested inside a collapsed subtree, continue iterating
113
- while (next instanceof HTMLElement && next.parentElement?.closest('[role=treeitem][aria-expanded=false]')) {
107
+ while (next instanceof HTMLElement && collapsedParent(next, root)) {
114
108
  next = direction === 'next' ? walker.nextNode() : walker.previousNode();
115
109
  }
116
110
  return next instanceof HTMLElement ? next : undefined;
117
111
  }
112
+ function collapsedParent(node, root) {
113
+ for (const ancestor of root.querySelectorAll('[role=treeitem][aria-expanded=false]')) {
114
+ if (node === ancestor)
115
+ continue;
116
+ if (ancestor.closest('li')?.contains(node)) {
117
+ return ancestor;
118
+ }
119
+ }
120
+ return null;
121
+ }
118
122
  function getFirstChildElement(element) {
119
123
  const firstChild = element.querySelector('[role=treeitem]');
120
124
  return firstChild instanceof HTMLElement ? firstChild : undefined;
@@ -24,13 +24,6 @@ export function useRovingTabIndex(containerEl: TreeViewElement) {
24
24
  return getNextFocusableElement(from, event) ?? from
25
25
  },
26
26
  focusInStrategy: () => {
27
- // Don't try to execute the focusInStrategy if focus is coming from a click.
28
- // The clicked row will receive focus correctly by default.
29
- // If a chevron is clicked, setting the focus through the focuszone will prevent its toggle.
30
- // if (mouseDownRef.current) {
31
- // return undefined
32
- // }
33
-
34
27
  let currentItem = containerEl.querySelector('[aria-current]')
35
28
  currentItem = currentItem?.checkVisibility() ? currentItem : null
36
29
 
@@ -137,13 +130,25 @@ function getVisibleElement(element: HTMLElement, direction: 'next' | 'previous')
137
130
  let next = direction === 'next' ? walker.nextNode() : walker.previousNode()
138
131
 
139
132
  // If next element is nested inside a collapsed subtree, continue iterating
140
- while (next instanceof HTMLElement && next.parentElement?.closest('[role=treeitem][aria-expanded=false]')) {
133
+ while (next instanceof HTMLElement && collapsedParent(next, root)) {
141
134
  next = direction === 'next' ? walker.nextNode() : walker.previousNode()
142
135
  }
143
136
 
144
137
  return next instanceof HTMLElement ? next : undefined
145
138
  }
146
139
 
140
+ function collapsedParent(node: Element, root: Element): Element | null {
141
+ for (const ancestor of root.querySelectorAll('[role=treeitem][aria-expanded=false]')) {
142
+ if (node === ancestor) continue
143
+
144
+ if (ancestor.closest('li')?.contains(node)) {
145
+ return ancestor
146
+ }
147
+ }
148
+
149
+ return null
150
+ }
151
+
147
152
  function getFirstChildElement(element: HTMLElement): HTMLElement | undefined {
148
153
  const firstChild = element.querySelector('[role=treeitem]')
149
154
  return firstChild instanceof HTMLElement ? firstChild : undefined
@@ -14,9 +14,11 @@ export declare class TreeViewSubTreeNodeElement extends HTMLElement {
14
14
  loadingIndicator: HTMLElement;
15
15
  loadingFailureMessage: HTMLElement;
16
16
  retryButton: HTMLButtonElement;
17
- expanded: boolean;
18
- loadingState: LoadingState;
19
17
  connectedCallback(): void;
18
+ get expanded(): boolean;
19
+ set expanded(newValue: boolean);
20
+ get loadingState(): LoadingState;
21
+ set loadingState(newState: LoadingState);
20
22
  get selectStrategy(): string;
21
23
  disconnectedCallback(): void;
22
24
  handleEvent(event: Event): void;