Skip to content

Commit ebad612

Browse files
committed
Allow navigation between the input and the actual tree via arrow keys
1 parent 1c42725 commit ebad612

4 files changed

Lines changed: 83 additions & 2 deletions

File tree

.changeset/wet-carrots-appear.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@openproject/primer-view-components': patch
3+
---
4+
5+
Allow navigation with the FilterableTreeView from the input to the tree (and back)

app/components/primer/open_project/filterable_tree_view.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export class FilterableTreeViewElement extends HTMLElement {
4949
this.addEventListener('treeViewNodeChecked', this, {signal})
5050
this.addEventListener('itemActivated', this, {signal})
5151
this.addEventListener('input', this, {signal})
52+
this.addEventListener('keydown', this, {signal})
5253

5354
if (this.#isAsyncMode) {
5455
void this.#fetchAndReplaceTree()
@@ -77,11 +78,17 @@ export class FilterableTreeViewElement extends HTMLElement {
7778
if (event.target === this.filterModeControl) {
7879
this.#handleFilterModeEvent(event)
7980
} else if (event.target === this.filterInput) {
80-
this.#handleFilterInputEvent(event)
81+
if (event.type === 'keydown') {
82+
this.#handleFilterInputKeyDown(event as KeyboardEvent)
83+
} else {
84+
this.#handleFilterInputEvent(event)
85+
}
8186
} else if (event.target === this.includeSubItemsCheckBox) {
8287
this.#handleIncludeSubItemsCheckBoxEvent(event)
8388
} else if (event.target instanceof TreeViewElement || event.target instanceof TreeViewSubTreeNodeElement) {
8489
this.#handleTreeViewEvent(event)
90+
} else if (event.type === 'keydown' && this.treeViewList?.contains(event.target as Node)) {
91+
this.#handleTreeKeyDown(event as KeyboardEvent)
8592
}
8693
}
8794

@@ -231,6 +238,27 @@ export class FilterableTreeViewElement extends HTMLElement {
231238
}
232239
}
233240

241+
#handleFilterInputKeyDown(event: KeyboardEvent) {
242+
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
243+
244+
const treeitems = [...(this.treeViewList?.querySelectorAll<HTMLElement>('[role=treeitem]') ?? [])]
245+
const visibleItems = treeitems.filter(
246+
item => !item.closest('li[hidden]') && !item.closest('tree-view-sub-tree-node[hidden]'),
247+
)
248+
249+
if (visibleItems.length === 0) return
250+
251+
event.preventDefault()
252+
const focusTarget = event.key === 'ArrowDown' ? visibleItems[0] : visibleItems[visibleItems.length - 1]
253+
focusTarget.focus()
254+
}
255+
256+
#handleTreeKeyDown(event: KeyboardEvent) {
257+
if (event.key !== 'Escape') return
258+
event.preventDefault()
259+
this.filterInput.focus()
260+
}
261+
234262
#handleIncludeSubItemsCheckBoxEvent(event: Event) {
235263
if (!this.treeView) return
236264
if (event.type !== 'input') return

previews/primer/open_project/filterable_tree_view_preview/default.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<%= render(Primer::OpenProject::FilterableTreeView.new) do |tree| %>
1+
<%= render(Primer::OpenProject::FilterableTreeView.new(filter_input_arguments: {autocomplete: "off"})) do |tree| %>
22
<% tree.with_sub_tree(label: "Students", expanded: expanded) do |hogwarts| %>
33
<% hogwarts.with_sub_tree(label: "Ravenclaw", expanded: expanded) do |ravenclaw| %>
44
<% ravenclaw.with_leaf(label: "Luna Lovegood") %>

test/system/open_project/filterable_tree_view_test.rb

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,14 @@
55

66
module OpenProject
77
class IntegrationFilterableTreeViewTest < System::TestCase
8+
include Primer::DriverTestHelpers
9+
include Primer::KeyboardTestHelpers
810
include Primer::TreeViewHelpers
911

12+
def active_element
13+
page.evaluate_script("document.activeElement")
14+
end
15+
1016
def test_filtering_matches_sub_trees
1117
visit_preview(:default)
1218

@@ -370,6 +376,48 @@ def test_form_submits_checked_nodes_when_filtering_for_selected_only
370376
assert_equal character["path"], ["Students", "Slytherin", "Draco Malfoy"]
371377
end
372378

379+
# ─── Keyboard navigation ──────────────────────────────────────────────────
380+
381+
def test_arrow_down_from_filter_input_focuses_first_treeitem
382+
visit_preview(:default)
383+
384+
find_field("Filter").click
385+
keyboard.type(:down)
386+
387+
assert_equal "treeitem", active_element["role"]
388+
assert_equal "Students", label_of(active_element)
389+
end
390+
391+
def test_arrow_up_from_filter_input_focuses_last_treeitem
392+
visit_preview(:default)
393+
394+
find_field("Filter").click
395+
keyboard.type(:up)
396+
397+
assert_equal "treeitem", active_element["role"]
398+
assert_equal "Rubeus Hagrid", label_of(active_element)
399+
end
400+
401+
def test_arrow_down_after_filtering_focuses_first_visible_treeitem
402+
visit_preview(:default)
403+
404+
fill_in "Filter", with: "Luna"
405+
find_field("Filter").click
406+
keyboard.type(:down)
407+
408+
assert_equal "treeitem", active_element["role"]
409+
assert_equal "Students", label_of(active_element)
410+
end
411+
412+
def test_escape_from_treeitem_returns_focus_to_filter_input
413+
visit_preview(:default)
414+
415+
node_at_path("Students").evaluate_script("this.focus()")
416+
keyboard.type(:escape)
417+
418+
assert_equal "input", active_element.tag_name
419+
end
420+
373421
# ─── Async: initial load ─────────────────────────────────────────────────
374422

375423
HOGWARTS = "Hogwarts School of Witchcraft and Wizardry"

0 commit comments

Comments
 (0)