openproject-primer_view_components 0.66.2 → 0.68.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/app/assets/javascripts/components/primer/open_project/tree_view/tree_view.d.ts +3 -0
  4. data/app/assets/javascripts/components/primer/open_project/tree_view/tree_view_sub_tree_node_element.d.ts +1 -0
  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/page_header/title.rb +1 -1
  10. data/app/components/primer/open_project/tree_view/node.html.erb +2 -2
  11. data/app/components/primer/open_project/tree_view/node.rb +49 -26
  12. data/app/components/primer/open_project/tree_view/skeleton_loader.html.erb +1 -1
  13. data/app/components/primer/open_project/tree_view/spinner_loader.html.erb +2 -2
  14. data/app/components/primer/open_project/tree_view/sub_tree.html.erb +1 -1
  15. data/app/components/primer/open_project/tree_view/sub_tree.rb +8 -1
  16. data/app/components/primer/open_project/tree_view/sub_tree_node.rb +9 -3
  17. data/app/components/primer/open_project/tree_view/tree_view.d.ts +3 -0
  18. data/app/components/primer/open_project/tree_view/tree_view.js +63 -10
  19. data/app/components/primer/open_project/tree_view/tree_view.ts +68 -10
  20. data/app/components/primer/open_project/tree_view/tree_view_roving_tab_index.js +11 -7
  21. data/app/components/primer/open_project/tree_view/tree_view_roving_tab_index.ts +13 -8
  22. data/app/components/primer/open_project/tree_view/tree_view_sub_tree_node_element.d.ts +1 -0
  23. data/app/components/primer/open_project/tree_view/tree_view_sub_tree_node_element.js +52 -21
  24. data/app/components/primer/open_project/tree_view/tree_view_sub_tree_node_element.ts +60 -20
  25. data/app/components/primer/open_project/tree_view.css +1 -1
  26. data/app/components/primer/open_project/tree_view.css.json +9 -6
  27. data/app/components/primer/open_project/tree_view.css.map +1 -1
  28. data/app/components/primer/open_project/tree_view.pcss +53 -38
  29. data/app/components/primer/open_project/tree_view.rb +88 -24
  30. data/lib/primer/view_components/version.rb +2 -2
  31. data/previews/primer/open_project/tree_view_preview/async_alpha.html.erb +12 -0
  32. data/previews/primer/open_project/tree_view_preview/buttons.html.erb +10 -0
  33. data/previews/primer/open_project/tree_view_preview/links.html.erb +17 -0
  34. data/previews/primer/open_project/tree_view_preview.rb +53 -3
  35. data/static/arguments.json +38 -2
  36. data/static/constants.json +18 -1
  37. data/static/info_arch.json +121 -3
  38. data/static/previews.json +52 -0
  39. metadata +5 -2
@@ -9,7 +9,7 @@ module Primer
9
9
  status :open_project
10
10
 
11
11
  HEADING_TAG_OPTIONS = [:h1, :h2, :h3, :h4, :h5, :h6].freeze
12
- HEADING_TAG_FALLBACK = :h1
12
+ HEADING_TAG_FALLBACK = :h2
13
13
 
14
14
  renders_one :editable_form, lambda { |model: false, update_path:, cancel_path:, input_name: :title, method: :put, label: I18n.t(:label_title), placeholder: I18n.t(:label_title), **system_arguments|
15
15
  primer_form_with(
@@ -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,9 @@ 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;
25
+ expandAncestorsForNode(node: HTMLElement): void;
23
26
  infoFromNode(node: Element, newCheckedValue?: TreeViewCheckedValue): TreeViewNodeInfo | null;
24
27
  }
25
28
  declare global {
@@ -15,7 +15,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
15
15
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
16
16
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
17
17
  };
18
- var _TreeViewElement_instances, _TreeViewElement_abortController, _TreeViewElement_eventIsActivation, _TreeViewElement_nodeForEvent, _TreeViewElement_handleNodeEvent, _TreeViewElement_eventIsCheckboxToggle, _TreeViewElement_handleCheckboxToggle, _TreeViewElement_handleNodeActivated, _TreeViewElement_handleNodeFocused, _TreeViewElement_handleNodeKeyboardEvent, _TreeViewElement_setNodeCheckedValue;
18
+ var _TreeViewElement_instances, _TreeViewElement_abortController, _TreeViewElement_autoExpandFrom, _TreeViewElement_eventIsActivation, _TreeViewElement_nodeForEvent, _TreeViewElement_handleNodeEvent, _TreeViewElement_eventIsCheckboxToggle, _TreeViewElement_handleCheckboxToggle, _TreeViewElement_handleNodeActivated, _TreeViewElement_handleNodeFocused, _TreeViewElement_handleNodeKeyboardEvent, _TreeViewElement_setNodeCheckedValue;
19
19
  import { controller } from '@github/catalyst';
20
20
  import { useRovingTabIndex } from './tree_view_roving_tab_index';
21
21
  let TreeViewElement = class TreeViewElement extends HTMLElement {
@@ -30,6 +30,24 @@ let TreeViewElement = class TreeViewElement extends HTMLElement {
30
30
  this.addEventListener('focusin', this, { signal });
31
31
  this.addEventListener('keydown', this, { signal });
32
32
  useRovingTabIndex(this);
33
+ // catch-all for any straggler nodes that aren't available when connectedCallback runs
34
+ new MutationObserver(mutations => {
35
+ for (const mutation of mutations) {
36
+ for (const addedNode of mutation.addedNodes) {
37
+ if (!(addedNode instanceof HTMLElement))
38
+ continue;
39
+ // eslint-disable-next-line custom-elements/no-dom-traversal-in-connectedcallback
40
+ if (addedNode.querySelector('[aria-expanded=true]')) {
41
+ __classPrivateFieldGet(this, _TreeViewElement_instances, "m", _TreeViewElement_autoExpandFrom).call(this, addedNode);
42
+ }
43
+ }
44
+ }
45
+ }).observe(this, { childList: true, subtree: true });
46
+ // eslint-disable-next-line github/no-then -- We don't want to wait for this to resolve, just get on with it
47
+ customElements.whenDefined('tree-view-sub-tree-node').then(() => {
48
+ // depends on TreeViewSubTreeNodeElement#eachAncestorSubTreeNode, which may not be defined yet
49
+ __classPrivateFieldGet(this, _TreeViewElement_instances, "m", _TreeViewElement_autoExpandFrom).call(this, this);
50
+ });
33
51
  }
34
52
  disconnectedCallback() {
35
53
  __classPrivateFieldGet(this, _TreeViewElement_abortController, "f").abort();
@@ -126,6 +144,22 @@ let TreeViewElement = class TreeViewElement extends HTMLElement {
126
144
  getNodeCheckedValue(node) {
127
145
  return (node.getAttribute('aria-checked') || 'false');
128
146
  }
147
+ nodeHasCheckBox(node) {
148
+ return node.querySelector('.TreeViewItemCheckbox') !== null;
149
+ }
150
+ nodeHasNativeAction(node) {
151
+ return node instanceof HTMLAnchorElement || node instanceof HTMLButtonElement;
152
+ }
153
+ expandAncestorsForNode(node) {
154
+ const subTreeNode = node.closest('tree-view-sub-tree-node');
155
+ if (!subTreeNode)
156
+ return;
157
+ for (const ancestor of subTreeNode.eachAncestorSubTreeNode()) {
158
+ if (!ancestor.expanded) {
159
+ ancestor.expand();
160
+ }
161
+ }
162
+ }
129
163
  // PRIVATE API METHOD
130
164
  //
131
165
  // This would normally be marked private, but it's called by TreeViewSubTreeNodes
@@ -146,6 +180,11 @@ let TreeViewElement = class TreeViewElement extends HTMLElement {
146
180
  };
147
181
  _TreeViewElement_abortController = new WeakMap();
148
182
  _TreeViewElement_instances = new WeakSet();
183
+ _TreeViewElement_autoExpandFrom = function _TreeViewElement_autoExpandFrom(root) {
184
+ for (const element of root.querySelectorAll('[aria-expanded=true]')) {
185
+ this.expandAncestorsForNode(element);
186
+ }
187
+ };
149
188
  _TreeViewElement_eventIsActivation = function _TreeViewElement_eventIsActivation(event) {
150
189
  return event.type === 'click';
151
190
  };
@@ -161,7 +200,7 @@ _TreeViewElement_nodeForEvent = function _TreeViewElement_nodeForEvent(event) {
161
200
  return node;
162
201
  };
163
202
  _TreeViewElement_handleNodeEvent = function _TreeViewElement_handleNodeEvent(node, event) {
164
- if (__classPrivateFieldGet(this, _TreeViewElement_instances, "m", _TreeViewElement_eventIsCheckboxToggle).call(this, event)) {
203
+ if (__classPrivateFieldGet(this, _TreeViewElement_instances, "m", _TreeViewElement_eventIsCheckboxToggle).call(this, event, node)) {
165
204
  __classPrivateFieldGet(this, _TreeViewElement_instances, "m", _TreeViewElement_handleCheckboxToggle).call(this, node);
166
205
  }
167
206
  else if (__classPrivateFieldGet(this, _TreeViewElement_instances, "m", _TreeViewElement_eventIsActivation).call(this, event)) {
@@ -174,8 +213,8 @@ _TreeViewElement_handleNodeEvent = function _TreeViewElement_handleNodeEvent(nod
174
213
  __classPrivateFieldGet(this, _TreeViewElement_instances, "m", _TreeViewElement_handleNodeKeyboardEvent).call(this, event, node);
175
214
  }
176
215
  };
177
- _TreeViewElement_eventIsCheckboxToggle = function _TreeViewElement_eventIsCheckboxToggle(event) {
178
- return event.type === 'click' && event.target.closest('.TreeViewItemCheckbox') !== null;
216
+ _TreeViewElement_eventIsCheckboxToggle = function _TreeViewElement_eventIsCheckboxToggle(event, node) {
217
+ return event.type === 'click' && this.nodeHasCheckBox(node);
179
218
  };
180
219
  _TreeViewElement_handleCheckboxToggle = function _TreeViewElement_handleCheckboxToggle(node) {
181
220
  // only handle checking of leaf nodes
@@ -190,6 +229,10 @@ _TreeViewElement_handleCheckboxToggle = function _TreeViewElement_handleCheckbox
190
229
  }
191
230
  };
192
231
  _TreeViewElement_handleNodeActivated = function _TreeViewElement_handleNodeActivated(node) {
232
+ // do not emit activation events for buttons and anchors, since it is assumed any activation
233
+ // behavior for these element types is user- or browser-defined
234
+ if (!(node instanceof HTMLDivElement))
235
+ return;
193
236
  const path = this.getNodePath(node);
194
237
  const activationSuccess = this.dispatchEvent(new CustomEvent('treeViewBeforeNodeActivated', {
195
238
  bubbles: true,
@@ -198,7 +241,10 @@ _TreeViewElement_handleNodeActivated = function _TreeViewElement_handleNodeActiv
198
241
  }));
199
242
  if (!activationSuccess)
200
243
  return;
201
- this.toggleAtPath(path);
244
+ // navigate or trigger button, don't toggle
245
+ if (!this.nodeHasNativeAction(node)) {
246
+ this.toggleAtPath(path);
247
+ }
202
248
  this.dispatchEvent(new CustomEvent('treeViewNodeActivated', {
203
249
  bubbles: true,
204
250
  detail: this.infoFromNode(node),
@@ -215,12 +261,19 @@ _TreeViewElement_handleNodeKeyboardEvent = function _TreeViewElement_handleNodeK
215
261
  }
216
262
  switch (event.key) {
217
263
  case ' ':
218
- event.preventDefault();
219
- if (this.getNodeCheckedValue(node) === 'true') {
220
- __classPrivateFieldGet(this, _TreeViewElement_instances, "m", _TreeViewElement_setNodeCheckedValue).call(this, node, 'false');
264
+ case 'Enter':
265
+ if (this.nodeHasCheckBox(node)) {
266
+ event.preventDefault();
267
+ if (this.getNodeCheckedValue(node) === 'true') {
268
+ __classPrivateFieldGet(this, _TreeViewElement_instances, "m", _TreeViewElement_setNodeCheckedValue).call(this, node, 'false');
269
+ }
270
+ else {
271
+ __classPrivateFieldGet(this, _TreeViewElement_instances, "m", _TreeViewElement_setNodeCheckedValue).call(this, node, 'true');
272
+ }
221
273
  }
222
- else {
223
- __classPrivateFieldGet(this, _TreeViewElement_instances, "m", _TreeViewElement_setNodeCheckedValue).call(this, node, 'true');
274
+ else if (node instanceof HTMLAnchorElement) {
275
+ // simulate click on space
276
+ node.click();
224
277
  }
225
278
  break;
226
279
  }
@@ -14,6 +14,32 @@ export class TreeViewElement extends HTMLElement {
14
14
  this.addEventListener('keydown', this, {signal})
15
15
 
16
16
  useRovingTabIndex(this)
17
+
18
+ // catch-all for any straggler nodes that aren't available when connectedCallback runs
19
+ new MutationObserver(mutations => {
20
+ for (const mutation of mutations) {
21
+ for (const addedNode of mutation.addedNodes) {
22
+ if (!(addedNode instanceof HTMLElement)) continue
23
+
24
+ // eslint-disable-next-line custom-elements/no-dom-traversal-in-connectedcallback
25
+ if (addedNode.querySelector('[aria-expanded=true]')) {
26
+ this.#autoExpandFrom(addedNode)
27
+ }
28
+ }
29
+ }
30
+ }).observe(this, {childList: true, subtree: true})
31
+
32
+ // eslint-disable-next-line github/no-then -- We don't want to wait for this to resolve, just get on with it
33
+ customElements.whenDefined('tree-view-sub-tree-node').then(() => {
34
+ // depends on TreeViewSubTreeNodeElement#eachAncestorSubTreeNode, which may not be defined yet
35
+ this.#autoExpandFrom(this)
36
+ })
37
+ }
38
+
39
+ #autoExpandFrom(root: HTMLElement) {
40
+ for (const element of root.querySelectorAll('[aria-expanded=true]')) {
41
+ this.expandAncestorsForNode(element as HTMLElement)
42
+ }
17
43
  }
18
44
 
19
45
  disconnectedCallback() {
@@ -44,7 +70,7 @@ export class TreeViewElement extends HTMLElement {
44
70
  }
45
71
 
46
72
  #handleNodeEvent(node: Element, event: Event) {
47
- if (this.#eventIsCheckboxToggle(event)) {
73
+ if (this.#eventIsCheckboxToggle(event, node)) {
48
74
  this.#handleCheckboxToggle(node)
49
75
  } else if (this.#eventIsActivation(event)) {
50
76
  this.#handleNodeActivated(node)
@@ -55,8 +81,8 @@ export class TreeViewElement extends HTMLElement {
55
81
  }
56
82
  }
57
83
 
58
- #eventIsCheckboxToggle(event: Event) {
59
- return event.type === 'click' && (event.target as HTMLElement).closest('.TreeViewItemCheckbox') !== null
84
+ #eventIsCheckboxToggle(event: Event, node: Element) {
85
+ return event.type === 'click' && this.nodeHasCheckBox(node)
60
86
  }
61
87
 
62
88
  #handleCheckboxToggle(node: Element) {
@@ -72,6 +98,10 @@ export class TreeViewElement extends HTMLElement {
72
98
  }
73
99
 
74
100
  #handleNodeActivated(node: Element) {
101
+ // do not emit activation events for buttons and anchors, since it is assumed any activation
102
+ // behavior for these element types is user- or browser-defined
103
+ if (!(node instanceof HTMLDivElement)) return
104
+
75
105
  const path = this.getNodePath(node)
76
106
 
77
107
  const activationSuccess = this.dispatchEvent(
@@ -84,7 +114,10 @@ export class TreeViewElement extends HTMLElement {
84
114
 
85
115
  if (!activationSuccess) return
86
116
 
87
- this.toggleAtPath(path)
117
+ // navigate or trigger button, don't toggle
118
+ if (!this.nodeHasNativeAction(node)) {
119
+ this.toggleAtPath(path)
120
+ }
88
121
 
89
122
  this.dispatchEvent(
90
123
  new CustomEvent('treeViewNodeActivated', {
@@ -107,12 +140,18 @@ export class TreeViewElement extends HTMLElement {
107
140
 
108
141
  switch (event.key) {
109
142
  case ' ':
110
- event.preventDefault()
111
-
112
- if (this.getNodeCheckedValue(node) === 'true') {
113
- this.#setNodeCheckedValue(node, 'false')
114
- } else {
115
- this.#setNodeCheckedValue(node, 'true')
143
+ case 'Enter':
144
+ if (this.nodeHasCheckBox(node)) {
145
+ event.preventDefault()
146
+
147
+ if (this.getNodeCheckedValue(node) === 'true') {
148
+ this.#setNodeCheckedValue(node, 'false')
149
+ } else {
150
+ this.#setNodeCheckedValue(node, 'true')
151
+ }
152
+ } else if (node instanceof HTMLAnchorElement) {
153
+ // simulate click on space
154
+ node.click()
116
155
  }
117
156
 
118
157
  break
@@ -225,6 +264,25 @@ export class TreeViewElement extends HTMLElement {
225
264
  return (node.getAttribute('aria-checked') || 'false') as TreeViewCheckedValue
226
265
  }
227
266
 
267
+ nodeHasCheckBox(node: Element): boolean {
268
+ return node.querySelector('.TreeViewItemCheckbox') !== null
269
+ }
270
+
271
+ nodeHasNativeAction(node: Element): boolean {
272
+ return node instanceof HTMLAnchorElement || node instanceof HTMLButtonElement
273
+ }
274
+
275
+ expandAncestorsForNode(node: HTMLElement) {
276
+ const subTreeNode = node.closest('tree-view-sub-tree-node') as TreeViewSubTreeNodeElement
277
+ if (!subTreeNode) return
278
+
279
+ for (const ancestor of subTreeNode.eachAncestorSubTreeNode()) {
280
+ if (!ancestor.expanded) {
281
+ ancestor.expand()
282
+ }
283
+ }
284
+ }
285
+
228
286
  // PRIVATE API METHOD
229
287
  //
230
288
  // 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
@@ -28,6 +28,7 @@ export declare class TreeViewSubTreeNodeElement extends HTMLElement {
28
28
  get nodes(): NodeListOf<Element>;
29
29
  eachDirectDescendantNode(): Generator<Element>;
30
30
  eachDescendantNode(): Generator<Element>;
31
+ eachAncestorSubTreeNode(): Generator<TreeViewSubTreeNodeElement>;
31
32
  get isEmpty(): boolean;
32
33
  get treeView(): TreeViewElement | null;
33
34
  toggleChecked(): void;