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 @@ - +<%= column_tag id: "closed-cards", name: "Done", drop_url: columns_card_drops_closure_path("__id__"), class: "cards--on-deck", style: "--card-color: var(--color-card-complete);", + data: { + drag_and_strum_target: "container", + collapsible_columns_target: "column", + action: "focus->collapsible-columns#focusOnColumn" + } do %> +
+ <%= render "boards/show/expander", title: "Done", count: board.cards.closed.count, column_id: "closed-cards" %> + <%= render "boards/show/menu/maximize", column_path: board_columns_closed_path(board) %> +
+ <%= column_frame_tag :closed_column, src: board_columns_closed_path(board) %> +<% end %> diff --git a/app/views/boards/show/_column.html.erb b/app/views/boards/show/_column.html.erb index c763479ef..35beb5338 100644 --- a/app/views/boards/show/_column.html.erb +++ b/app/views/boards/show/_column.html.erb @@ -1,23 +1,18 @@ - + <%= link_to board_column_path(column.board, column), class: "cards__maximize-button btn btn--circle txt-x-small borderless", data: { turbo_frame: "_top" } do %> + <%= icon_tag "grid", class: "translucent" %> + Maximize column + <% end %> + + <%= column_frame_tag dom_id(column, :cards), src: board_column_path(column.board, column) %> +<% end %> diff --git a/app/views/boards/show/_columns.html.erb b/app/views/boards/show/_columns.html.erb index 2d2553d12..a7f8502e3 100644 --- a/app/views/boards/show/_columns.html.erb +++ b/app/views/boards/show/_columns.html.erb @@ -1,17 +1,23 @@ <%= tag.div class: "card-columns hide-scrollbar", data: { - controller: "collapsible-columns drag-and-drop drag-and-strum", + controller: "collapsible-columns drag-and-drop drag-and-strum navigable-list", 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, collapsible_columns_collapsed_class: "is-collapsed", collapsible_columns_no_transitions_class: "no-transitions", collapsible_columns_title_not_visible_class: "is-off-screen", + navigable_list_supports_vertical_navigation_value: false, + navigable_list_has_nested_navigation_value: true, + navigable_list_prevent_handled_keys_value: true, + navigable_list_auto_select_value: false, action: " + keydown->navigable-list#navigate dragstart->drag-and-drop#dragStart dragover->drag-and-drop#dragOver dragenter->drag-and-strum#dragEnter drop->drag-and-drop#drop - dragend->drag-and-drop#dragEnd" } do %> + dragend->drag-and-drop#dragEnd + click@document->navigable-list#deselectWhenClickingOutside" } do %>
<%= render "boards/show/not_now", board: board %>
diff --git a/app/views/boards/show/_expander.html.erb b/app/views/boards/show/_expander.html.erb index d01debadf..be5768e21 100644 --- a/app/views/boards/show/_expander.html.erb +++ b/app/views/boards/show/_expander.html.erb @@ -1,4 +1,4 @@ -