diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css
index 217f9a5c2..633281269 100644
--- a/app/assets/stylesheets/card-columns.css
+++ b/app/assets/stylesheets/card-columns.css
@@ -67,6 +67,7 @@
--column-color: color-mix(in srgb, var(--card-color) 15%, var(--color-canvas));
inline-size: var(--column-width-expanded);
+ outline: none;
position: relative;
scroll-snap-align: start;
@@ -111,7 +112,6 @@
}
}
-
@media (any-hover: hover) {
.card:has(.card__background img:not([src=""])):hover .card__background img:not([src=""]) {
filter: blur(3px) brightness(1.2);
@@ -172,6 +172,11 @@
.cards--grid & {
display: contents;
}
+
+ [aria-selected] & .card[aria-selected] {
+ outline: var(--focus-ring-size) solid var(--color-selected-dark);
+ outline-offset: var(--focus-ring-offset);
+ }
}
.cards__new-column {
diff --git a/app/helpers/columns_helper.rb b/app/helpers/columns_helper.rb
index 56699d584..67c7cdca0 100644
--- a/app/helpers/columns_helper.rb
+++ b/app/helpers/columns_helper.rb
@@ -9,9 +9,38 @@ module ColumnsHelper
data: { turbo_frame: "_top" }
end
+ def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, data: {}, **properties, &block)
+ classes = token_list("cards", properties.delete(:class), "is-collapsed": collapsed)
+
+ data = {
+ drag_and_drop_target: "container",
+ navigable_list_target: "item",
+ column_name: name,
+ drag_and_drop_url: drop_url
+ }.merge(data)
+
+ data[:action] = token_list(
+ "turbo:before-morph-attribute->collapsible-columns#preventToggle",
+ "focus->navigable-list#select",
+ data.delete(:action)
+ )
+
+ tag.section(id: id, class: classes, tabindex: "0", "aria-selected": selected, data: data, **properties) do
+ tag.div(class: "cards__transition-container", data: {
+ controller: "navigable-list",
+ navigable_list_supports_horizontal_navigation_value: "false",
+ navigable_list_prevent_handled_keys_value: "true",
+ navigable_list_auto_select_value: "false",
+ navigable_list_actionable_items_value: "true",
+ navigable_list_only_act_on_focused_items_value: "true",
+ action: "keydown->navigable-list#navigate"
+ }, &block)
+ end
+ end
+
def column_frame_tag(id, src: nil, data: {}, **options, &block)
data = data.reverse_merge \
- "drag-and-drop-refresh": true,
+ drag_and_drop_refresh: true,
controller: "frame",
action: "turbo:before-frame-render->frame#morphRender turbo:before-morph-element->frame#morphReload"
options[:refresh] = :morph if src.present?
diff --git a/app/javascript/controllers/collapsible_columns_controller.js b/app/javascript/controllers/collapsible_columns_controller.js
index b11821f3f..2c97fcd09 100644
--- a/app/javascript/controllers/collapsible_columns_controller.js
+++ b/app/javascript/controllers/collapsible_columns_controller.js
@@ -40,6 +40,13 @@ export default class extends Controller {
await this.#restoreColumnsDisablingTransitions()
}
+ focusOnColumn({ target }) {
+ if (this.#isCollapsed(target)) {
+ this.#collapseAllExcept(target)
+ this.#expand(target)
+ }
+ }
+
async #restoreColumnsDisablingTransitions() {
this.#disableTransitions()
this.#restoreColumns()
diff --git a/app/javascript/controllers/navigable_list_controller.js b/app/javascript/controllers/navigable_list_controller.js
index 94acaf28b..fe5fca86c 100644
--- a/app/javascript/controllers/navigable_list_controller.js
+++ b/app/javascript/controllers/navigable_list_controller.js
@@ -8,11 +8,21 @@ 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 },
+ supportsHorizontalNavigation: { type: Boolean, default: true },
+ supportsVerticalNavigation: { type: Boolean, default: true },
+ hasNestedNavigation: { type: Boolean, default: false },
+ preventHandledKeys: { type: Boolean, default: false },
+ autoSelect: { type: Boolean, default: true },
+ onlyActOnFocusedItems: { type: Boolean, default: false }
}
connect() {
- this.reset()
+ if (this.autoSelectValue) {
+ this.reset()
+ } else {
+ this.#activateManualSelection()
+ }
}
// Actions
@@ -27,10 +37,11 @@ export default class extends Controller {
navigate(event) {
this.#keyHandlers[event.key]?.call(this, event)
+ this.#relayNavigationToParentNavigableList(event)
}
select({ target }) {
- this.#setCurrentFrom(target)
+ this.selectItem(target, true)
}
selectCurrentOrReset(event) {
@@ -49,12 +60,109 @@ export default class extends Controller {
this.#setCurrentFrom(this.#visibleItems[this.#visibleItems.length - 1])
}
+ deselectWhenClickingOutside(event) {
+ if (this.element.contains(event.target)) {
+ return
+ }
+
+ this.#clearSelection()
+ }
+
+ // Public
+
+ async selectItem(item, skipFocus = false) {
+ await this.#selectCurrentElementInParent()
+
+ this.#clearSelection()
+ item.setAttribute(this.selectionAttributeValue, "true")
+ this.currentItem = item
+ this.#refreshActiveDescendant()
+
+ await nextFrame()
+
+ this.currentItem.scrollIntoView({ block: "nearest", inline: "nearest" })
+ if (this.hasNestedNavigationValue) { this.#activateNestedNavigableList() }
+
+ if (!skipFocus && this.focusOnSelectionValue) { this.currentItem.focus() }
+ }
+
+ isSelected(item) {
+ return item === this.currentItem
+ }
+
// Private
- get #visibleItems() {
- return this.itemTargets.filter(item => {
- return item.checkVisibility() && !item.hidden
- })
+ async #setCurrentFrom(element) {
+ const selectedItem = this.#visibleItems.find(item => item.contains(element))
+
+ if (selectedItem) {
+ await this.selectItem(selectedItem)
+ }
+ }
+
+ get #parentNavigableListController() {
+ const parentNavigableList = this.element.parentElement?.closest("[data-controller~='navigable-list']")
+ if (parentNavigableList) {
+ return this.application.getControllerForElementAndIdentifier(parentNavigableList, "navigable-list")
+ }
+ return null
+ }
+
+ async #selectCurrentElementInParent() {
+ const parentController = this.#parentNavigableListController
+ if (parentController) {
+ const parentItem = this.element.closest("[data-navigable-list-target~='item']")
+ const isAlreadySelected = parentController.isSelected(parentItem)
+ if (!isAlreadySelected) {
+ await parentController.selectItem(parentItem, true)
+ }
+ }
+ }
+
+ #clearSelection() {
+ for (const item of this.itemTargets) {
+ item.removeAttribute(this.selectionAttributeValue)
+ }
+ }
+
+ #refreshActiveDescendant() {
+ const id = this.currentItem?.getAttribute("id")
+ if (this.hasInputTarget && id) {
+ this.inputTarget.setAttribute("aria-activedescendant", id)
+ }
+ }
+
+ #activateNestedNavigableList() {
+ const nestedController = this.#nestedNavigableListController()
+ if (nestedController) {
+ nestedController.selectCurrentOrReset()
+ return true
+ }
+ return false
+ }
+
+ #nestedNavigableListController() {
+ const nestedElement = this.currentItem?.querySelector('[data-controller~="navigable-list"]')
+ if (nestedElement) {
+ return this.application.getControllerForElementAndIdentifier(nestedElement, "navigable-list")
+ }
+ return null
+ }
+
+ #activateManualSelection() {
+ const preselectedItem = this.itemTargets.find(item => item.hasAttribute(this.selectionAttributeValue))
+ if (preselectedItem) {
+ this.#setCurrentFrom(preselectedItem)
+ }
+ }
+
+ // Stimulus won't let you handle keydown events with different handlers for the same (nested) stimulus controllers.
+ #relayNavigationToParentNavigableList(event) {
+ const parentController = this.#parentNavigableListController
+ if (parentController) {
+ parentController.element.focus()
+ parentController.navigate(event)
+ }
}
#selectPrevious() {
@@ -71,46 +179,26 @@ 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)
- }
- }
- }
-
- #clearSelection() {
- for (const item of this.itemTargets) {
- item.removeAttribute(this.selectionAttributeValue)
- }
- }
-
- #handleArrowKey(event, fn, preventDefault = true) {
+ #handleArrowKey(event, fn) {
if (event.shiftKey || event.metaKey || event.ctrlKey) { return }
fn.call()
- if (preventDefault) { event.preventDefault() }
+ if (this.preventHandledKeysValue) {
+ event.preventDefault()
+ }
}
#clickCurrentItem(event) {
- if (this.actionableItemsValue && this.currentItem && this.#visibleItems.length) {
+ if (this.actionableItemsValue && this.currentItem && this.#visibleItems.length && this.#isFocusContainedOnNavigableItem) {
const clickableElement = this.currentItem.querySelector("a,button") || this.currentItem
clickableElement.click()
event.preventDefault()
}
}
+ get #isFocusContainedOnNavigableItem() {
+ return !this.onlyActOnFocusedItemsValue || this.itemTargets.some(item => item === document.activeElement || item.contains(document.activeElement))
+ }
+
#toggleCurrentItem(event) {
if (this.actionableItemsValue && this.currentItem && this.#visibleItems.length) {
const toggleable = this.currentItem.querySelector("input[type=checkbox]")
@@ -126,20 +214,34 @@ export default class extends Controller {
}
}
+ get #visibleItems() {
+ return this.itemTargets.filter(item => {
+ return item.checkVisibility() && !item.hidden
+ })
+ }
+
#keyHandlers = {
ArrowDown(event) {
- const selectMethod = this.reverseNavigationValue ? this.#selectPrevious.bind(this) : this.#selectNext.bind(this)
- this.#handleArrowKey(event, selectMethod)
+ if (this.supportsVerticalNavigationValue) {
+ 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.supportsVerticalNavigationValue) {
+ 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.supportsHorizontalNavigationValue) {
+ this.#handleArrowKey(event, this.#selectNext.bind(this))
+ }
},
ArrowLeft(event) {
- this.#handleArrowKey(event, this.#selectPrevious.bind(this), false)
+ if (this.supportsHorizontalNavigationValue) {
+ this.#handleArrowKey(event, this.#selectPrevious.bind(this))
+ }
},
Enter(event) {
if (event.shiftKey) {
diff --git a/app/views/boards/show/_closed.html.erb b/app/views/boards/show/_closed.html.erb
index 51c587c78..0d4c772f4 100644
--- a/app/views/boards/show/_closed.html.erb
+++ b/app/views/boards/show/_closed.html.erb
@@ -1,16 +1,12 @@
-