From 3bfab09795c4ed5d096685a44de08c6894959f90 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 20 Nov 2025 09:34:08 +0100 Subject: [PATCH] Add keyboard navigation support for board columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add horizontal/vertical navigation configuration options to navigable_list controller - Support nested navigation (columns contain lists of cards) - Disable horizontal navigation within individual columns - Enable horizontal navigation at the column level - Refactor selection logic into reusable methods This allows users to navigate between columns with left/right arrow keys and between cards within a column using up/down arrow keys. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../controllers/navigable_list_controller.js | 59 ++++++++++++------- app/views/boards/show/_closed.html.erb | 1 + app/views/boards/show/_column.html.erb | 1 + app/views/boards/show/_columns.html.erb | 2 + app/views/boards/show/_not_now.html.erb | 1 + app/views/boards/show/_stream.html.erb | 1 + 6 files changed, 45 insertions(+), 20 deletions(-) diff --git a/app/javascript/controllers/navigable_list_controller.js b/app/javascript/controllers/navigable_list_controller.js index 94acaf28b..9af3b7912 100644 --- a/app/javascript/controllers/navigable_list_controller.js +++ b/app/javascript/controllers/navigable_list_controller.js @@ -8,7 +8,10 @@ export default class extends Controller { selectionAttribute: { type: String, default: "aria-selected" }, focusOnSelection: { type: Boolean, default: true }, actionableItems: { type: Boolean, default: false }, - reverseNavigation: { type: Boolean, default: false } + reverseNavigation: { type: Boolean, default: false }, + supportHorizontalNavigation: { type: Boolean, default: true }, + supportVerticalNavigation: { type: Boolean, default: true }, + hasNestedNavigation: { type: Boolean, default: false } } connect() { @@ -73,24 +76,32 @@ export default class extends Controller { async #setCurrentFrom(element) { const selectedItem = this.#visibleItems.find(item => item.contains(element)) - const id = selectedItem?.getAttribute("id") if (selectedItem) { - this.#clearSelection() - selectedItem.setAttribute(this.selectionAttributeValue, "true") - this.currentItem = selectedItem - await nextFrame() - try { - this.currentItem.scrollIntoView({ block: "nearest", inline: "nearest" }) - } catch (e) {} - - if (this.focusOnSelectionValue) { this.currentItem.focus() } - if (this.hasInputTarget && id) { - this.inputTarget.setAttribute("aria-activedescendant", id) - } + await this.#selectItem(selectedItem) } } + async #selectItem(item) { + this.#clearSelection() + item.setAttribute(this.selectionAttributeValue, "true") + this.currentItem = item + + await nextFrame() + + this.#scrollAndFocusOnSelectedItem() + } + + #scrollAndFocusOnSelectedItem() { + const id = this.currentItem?.getAttribute("id") + this.currentItem.scrollIntoView({ block: "nearest", inline: "nearest" }) + if (this.focusOnSelectionValue) { this.currentItem.focus() } + if (this.hasInputTarget && id) { + this.inputTarget.setAttribute("aria-activedescendant", id) + } + + } + #clearSelection() { for (const item of this.itemTargets) { item.removeAttribute(this.selectionAttributeValue) @@ -128,18 +139,26 @@ export default class extends Controller { #keyHandlers = { ArrowDown(event) { - const selectMethod = this.reverseNavigationValue ? this.#selectPrevious.bind(this) : this.#selectNext.bind(this) - this.#handleArrowKey(event, selectMethod) + if (this.supportVerticalNavigationValue) { + const selectMethod = this.reverseNavigationValue ? this.#selectPrevious.bind(this) : this.#selectNext.bind(this) + this.#handleArrowKey(event, selectMethod) + } }, ArrowUp(event) { - const selectMethod = this.reverseNavigationValue ? this.#selectNext.bind(this) : this.#selectPrevious.bind(this) - this.#handleArrowKey(event, selectMethod) + if (this.supportVerticalNavigationValue) { + const selectMethod = this.reverseNavigationValue ? this.#selectNext.bind(this) : this.#selectPrevious.bind(this) + this.#handleArrowKey(event, selectMethod) + } }, ArrowRight(event) { - this.#handleArrowKey(event, this.#selectNext.bind(this), false) + if (this.supportHorizontalNavigationValue) { + this.#handleArrowKey(event, this.#selectNext.bind(this), false) + } }, ArrowLeft(event) { - this.#handleArrowKey(event, this.#selectPrevious.bind(this), false) + if (this.supportHorizontalNavigationValue) { + this.#handleArrowKey(event, this.#selectPrevious.bind(this), false) + } }, Enter(event) { if (event.shiftKey) { diff --git a/app/views/boards/show/_closed.html.erb b/app/views/boards/show/_closed.html.erb index 126be0dbc..9562c321c 100644 --- a/app/views/boards/show/_closed.html.erb +++ b/app/views/boards/show/_closed.html.erb @@ -4,6 +4,7 @@ data-drag-and-strum-target="container" data-collapsible-columns-target="column" data-navigable-list-target="item" + data-navigable-list-support-horizontal-navigation-value="false" data-column-name="Done" data-action="turbo:before-morph-attribute->collapsible-columns#preventToggle focus->collapsible-columns#focusOnColumn" data-drag-and-drop-url="<%= columns_card_drops_closure_path("__id__") %>" diff --git a/app/views/boards/show/_column.html.erb b/app/views/boards/show/_column.html.erb index 1af574180..ab4b23dbf 100644 --- a/app/views/boards/show/_column.html.erb +++ b/app/views/boards/show/_column.html.erb @@ -5,6 +5,7 @@ data-drag-and-strum-target="container" data-collapsible-columns-target="column" data-navigable-list-target="item" + data-navigable-list-support-horizontal-navigation-value="false" data-controller="clicker" data-column-name="<%= column.name %>" data-action="turbo:before-morph-attribute->collapsible-columns#preventToggle turbo:before-stream-render@document->collapsible-columns#restoreState focus->collapsible-columns#focusOnColumn" diff --git a/app/views/boards/show/_columns.html.erb b/app/views/boards/show/_columns.html.erb index 4e5d12965..6535be917 100644 --- a/app/views/boards/show/_columns.html.erb +++ b/app/views/boards/show/_columns.html.erb @@ -6,6 +6,8 @@ collapsible_columns_collapsed_class: "is-collapsed", collapsible_columns_no_transitions_class: "no-transitions", collapsible_columns_title_not_visible_class: "is-off-screen", + navigable_list_support_vertical_navigation_value: false, + navigable_list_has_nested_navigation_value: true, action: " keydown->navigable-list#navigate dragstart->drag-and-drop#dragStart diff --git a/app/views/boards/show/_not_now.html.erb b/app/views/boards/show/_not_now.html.erb index eec937c19..8f41ba10f 100644 --- a/app/views/boards/show/_not_now.html.erb +++ b/app/views/boards/show/_not_now.html.erb @@ -4,6 +4,7 @@ data-drag-and-drop-target="container" data-drag-and-strum-target="container" data-navigable-list-target="item" + data-navigable-list-support-horizontal-navigation-value="false" data-column-name="Not Now" data-action="turbo:before-morph-attribute->collapsible-columns#preventToggle focus->collapsible-columns#focusOnColumn" data-drag-and-drop-url="<%= columns_card_drops_not_now_path("__id__") %>" diff --git a/app/views/boards/show/_stream.html.erb b/app/views/boards/show/_stream.html.erb index 47d812ec0..0d6b3c124 100644 --- a/app/views/boards/show/_stream.html.erb +++ b/app/views/boards/show/_stream.html.erb @@ -2,6 +2,7 @@ tabindex="0" data-drag-and-drop-target="container" data-navigable-list-target="item" + data-navigable-list-support-horizontal-navigation-value="false" data-column-name="Maybe?" data-action="turbo:before-morph-attribute->collapsible-columns#preventToggle" data-drag-and-drop-url="<%= columns_card_drops_stream_path("__id__") %>">