Merge pull request #2211 from basecamp/card-nav-hotkeys

Add hotkeys for triaging cards in columns
This commit is contained in:
Jason Zimdars
2025-12-23 09:45:24 -06:00
committed by GitHub
14 changed files with 228 additions and 7 deletions
+12 -2
View File
@@ -2,20 +2,30 @@ class Cards::ClosuresController < ApplicationController
include CardScoped
def create
capture_card_location
@card.close
refresh_stream_if_needed
respond_to do |format|
format.turbo_stream { render_card_replacement }
format.turbo_stream
format.json { head :no_content }
end
end
def destroy
@card.reopen
refresh_stream_after_reopen
respond_to do |format|
format.turbo_stream { render_card_replacement }
format.turbo_stream
format.json { head :no_content }
end
end
private
def refresh_stream_after_reopen
if @card.awaiting_triage?
set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first.preloaded
end
end
end
+3 -1
View File
@@ -2,10 +2,12 @@ class Cards::NotNowsController < ApplicationController
include CardScoped
def create
capture_card_location
@card.postpone
refresh_stream_if_needed
respond_to do |format|
format.turbo_stream { render_card_replacement }
format.turbo_stream
format.json { head :no_content }
end
end
+11
View File
@@ -17,4 +17,15 @@ module CardScoped
def render_card_replacement
render turbo_stream: turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload })
end
def capture_card_location
@source_column = @card.column
@was_in_stream = @card.awaiting_triage?
end
def refresh_stream_if_needed
if @was_in_stream
set_page_and_extract_portion_from @board.cards.awaiting_triage.latest.with_golden_first.preloaded
end
end
end
+2
View File
@@ -12,6 +12,7 @@ module ColumnsHelper
def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, card_color: "var(--color-card-default)", data: {}, **properties, &block)
classes = token_list("cards", properties.delete(:class), "is-collapsed": collapsed)
hotkeys_disabled = data[:card_hotkeys_disabled]
data = {
drag_and_drop_target: "container",
@@ -37,6 +38,7 @@ module ColumnsHelper
navigable_list_auto_select_value: "false",
navigable_list_actionable_items_value: "true",
navigable_list_only_act_on_focused_items_value: "true",
card_hotkeys_disabled: hotkeys_disabled,
action: "keydown->navigable-list#navigate"
}, &block)
end
@@ -0,0 +1,130 @@
import { Controller } from "@hotwired/stimulus"
import { post } from "@rails/request.js"
export default class extends Controller {
static outlets = [ "navigable-list" ]
connect() {
this.morphCompletePromise = null
this.morphCompleteResolver = null
}
handleKeydown(event) {
if (this.#shouldIgnore(event)) return
const handler = this.#keyHandlers[event.key.toLowerCase()]
if (handler) {
handler.call(this, event)
}
}
// Called when turbo:morph completes - resolves our waiting promise
handleMorphComplete() {
if (this.morphCompleteResolver) {
this.morphCompleteResolver()
this.morphCompleteResolver = null
this.morphCompletePromise = null
}
}
// Private
#shouldIgnore(event) {
const target = event.target
return target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable ||
target.closest("input, textarea, [contenteditable], lexxy-editor")
}
get #selectedCard() {
// Find the navigable-list that currently has focus
const focusedList = this.navigableListOutlets.find(list => list.hasFocus)
if (!focusedList) return null
const currentItem = focusedList.currentItem
if (currentItem?.classList.contains("card") && !this.#hotkeysDisabled(focusedList)) {
return { card: currentItem, controller: focusedList }
}
return null
}
async #postponeCard(event) {
const selection = this.#selectedCard
if (!selection) return
const url = selection.card.dataset.cardNotNowUrl
if (url) {
event.preventDefault()
await this.#performCardAction(url, selection)
}
}
async #closeCard(event) {
const selection = this.#selectedCard
if (!selection) return
const url = selection.card.dataset.cardClosureUrl
if (url) {
event.preventDefault()
await this.#performCardAction(url, selection)
}
}
async #assignToMe(event) {
const selection = this.#selectedCard
if (!selection) return
const url = selection.card.dataset.cardAssignToMeUrl
if (url) {
event.preventDefault()
await post(url, { responseKind: "turbo-stream" })
}
}
async #performCardAction(url, selection) {
const { controller } = selection
const visibleItems = controller.visibleItems
const currentIndex = visibleItems.indexOf(selection.card)
const wasLastItem = currentIndex === visibleItems.length - 1
// Set up promise to wait for morph completion
this.morphCompletePromise = new Promise(resolve => {
this.morphCompleteResolver = resolve
})
await post(url, { responseKind: "turbo-stream" })
// Wait for Turbo Stream morph to complete
await Promise.race([
this.morphCompletePromise,
new Promise(resolve => setTimeout(resolve, 200)) // Fallback timeout
])
// Select the next card (or previous if it was the last)
const newVisibleItems = controller.visibleItems
if (newVisibleItems.length === 0) {
controller.clearSelection()
return
}
if (wasLastItem) {
controller.selectLast()
} else {
const nextIndex = Math.min(currentIndex, newVisibleItems.length - 1)
if (newVisibleItems[nextIndex]) {
await controller.selectItem(newVisibleItems[nextIndex])
}
}
}
#hotkeysDisabled(navigableList) {
return navigableList?.element.dataset.cardHotkeysDisabled === "true"
}
#keyHandlers = {
"["(event) { this.#postponeCard(event) },
"]"(event) { this.#closeCard(event) },
m(event) { this.#assignToMe(event) }
}
}
@@ -45,6 +45,10 @@ export default class extends Controller {
this.selectItem(target, true)
}
hoverSelect({ currentTarget }) {
this.selectItem(currentTarget)
}
selectCurrentOrReset(event) {
if (this.currentItem) {
this.#setCurrentFrom(this.currentItem)
@@ -221,6 +225,20 @@ export default class extends Controller {
})
}
// Public accessors for card_hotkeys_controller outlet
get visibleItems() {
return this.#visibleItems
}
clearSelection() {
this.#clearSelection()
this.currentItem = null
}
get hasFocus() {
return this.element.contains(document.activeElement)
}
#keyHandlers = {
ArrowDown(event) {
if (this.supportsVerticalNavigationValue) {
@@ -253,6 +271,6 @@ export default class extends Controller {
} else {
this.#clickCurrentItem(event)
}
},
}
}
}
+1
View File
@@ -1,5 +1,6 @@
<%= column_tag id: "closed-cards", name: "Done", drop_url: columns_card_drops_closure_path("__id__"), class: "cards--closed", style: "--card-color: var(--color-card-complete);",
data: {
card_hotkeys_disabled: true,
drag_and_strum_target: "container",
collapsible_columns_target: "column",
action: "focus->collapsible-columns#focusOnColumn"
+4 -1
View File
@@ -1,5 +1,5 @@
<%= tag.div class: "card-columns hide-scrollbar", data: {
controller: "collapsible-columns drag-and-drop drag-and-strum navigable-list",
controller: "collapsible-columns drag-and-drop drag-and-strum navigable-list card-hotkeys",
drag_and_drop_dragged_item_class: "drag-and-drop__dragged-item",
drag_and_drop_hover_container_class: "drag-and-drop__hover-container",
collapsible_columns_board_value: board.id,
@@ -11,8 +11,11 @@
navigable_list_prevent_handled_keys_value: true,
navigable_list_auto_select_value: false,
navigable_list_auto_scroll_value: false,
card_hotkeys_navigable_list_outlet: ".cards__transition-container",
action: "
keydown->navigable-list#navigate
keydown->card-hotkeys#handleKeydown
turbo:morph@document->card-hotkeys#handleMorphComplete
dragstart->drag-and-drop#dragStart
dragover->drag-and-drop#dragOver
dragenter->drag-and-strum#dragEnter
+1
View File
@@ -1,5 +1,6 @@
<%= column_tag id: "not-now", name: "Not Now", drop_url: columns_card_drops_not_now_path("__id__"), class: "cards--on-deck", style: "--card-color: var(--color-card-complete);",
data: {
card_hotkeys_disabled: true,
collapsible_columns_target: "column",
drag_and_strum_target: "container",
action: "focus->collapsible-columns#focusOnColumn"
@@ -0,0 +1,9 @@
<%= turbo_stream.replace("closed-cards", partial: "boards/show/closed", method: :morph, locals: { board: @card.board }) %>
<% if @source_column %>
<%= turbo_stream.replace(dom_id(@source_column), partial: "boards/show/column", method: :morph, locals: { column: @source_column }) %>
<% elsif @was_in_stream %>
<%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
<% end %>
<%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %>
@@ -0,0 +1,9 @@
<%= turbo_stream.replace("closed-cards", partial: "boards/show/closed", method: :morph, locals: { board: @card.board }) %>
<% if @card.column %>
<%= turbo_stream.replace(dom_id(@card.column), partial: "boards/show/column", method: :morph, locals: { column: @card.column }) %>
<% elsif @card.awaiting_triage? %>
<%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
<% end %>
<%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %>
+17 -1
View File
@@ -1,6 +1,22 @@
<% draggable = local_assigns.fetch(:draggable, false) && card.published? %>
<%= card_article_tag card, class: "card", draggable: draggable, data: { id: card.number, drag_and_drop_target: "item", navigable_list_target: "item", css_variable_counter_target: "item" }, tabindex: 0 do %>
<% card_data = {
id: card.number,
drag_and_drop_target: "item",
navigable_list_target: "item",
css_variable_counter_target: "item"
} %>
<% if card.open? %>
<% card_data[:card_not_now_url] = card_not_now_path(card) %>
<% card_data[:card_closure_url] = card_closure_path(card) %>
<% card_data[:action] = "mouseenter->navigable-list#hoverSelect" %>
<% if Current.user %>
<% card_data[:card_assign_to_me_url] = card_assignments_path(card, params: { assignee_id: Current.user.id }) %>
<% end %>
<% end %>
<%= card_article_tag card, class: "card", draggable: draggable, data: card_data, tabindex: 0 do %>
<div class="flex flex-column flex-item-grow max-inline-size">
<%= link_to card_path(card), draggable: false, class: "card__link", title: card_title_tag(card), data: { action: "dialog#close", turbo_frame: "_top" } do %>
<span class="for-screen-reader"><%= card.title %></span>
@@ -12,7 +12,7 @@
</span>
</button>
<% if Current.user %>
<% if Current.user && !local_assigns[:preview] %>
<%= button_to "Assign to me", card_assignments_path(card, params: { assignee_id: Current.user.id }), method: :post, data: {controller: "hotkey", action: "keydown.m@document->hotkey#click" }, hidden: true %>
<% end %>
@@ -0,0 +1,9 @@
<%= turbo_stream.replace("not-now", partial: "boards/show/not_now", method: :morph, locals: { board: @card.board }) %>
<% if @source_column %>
<%= turbo_stream.replace(dom_id(@source_column), partial: "boards/show/column", method: :morph, locals: { column: @source_column }) %>
<% elsif @was_in_stream %>
<%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %>
<% end %>
<%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %>