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
@@ -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)
}
},
}
}
}