Merge branch 'main' into mobile-columns-pt-iii
* main: (55 commits) Add padding to upgrade message on larger screens Add test coverage for autolinking multiple URLs Add "noopener" to autolinks' rel attribute Avoid string manipulation when autolinking. Only bump z-index when nav is open Move nav and related elements above footer Delete Dockerfile.dev fix: use the right gh-cli arch package (#2232) Bump actions/attest-build-provenance from 3.0.0 to 3.1.0 (#2257) Bump docker/setup-buildx-action from 3.11.1 to 3.12.0 (#2256) Consider user avatars always public Implement authorization for Active Storage endpoints Update documentation Expose the card ID and URL on comments Don't run application recurring jobs in betas This link is reduntant if you're over the limits Add obvious upgrade button when you're out of storage Internaly only view Target comment elements more precisely Better name for helper method ...
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,13 @@ export default class extends Controller {
|
||||
static targets = [ "dialog" ]
|
||||
static values = {
|
||||
modal: { type: Boolean, default: false },
|
||||
sizing: { type: Boolean, default: true }
|
||||
sizing: { type: Boolean, default: true },
|
||||
autoOpen: { type: Boolean, default: false }
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.dialogTarget.setAttribute("aria-hidden", "true")
|
||||
if (this.autoOpenValue) this.open()
|
||||
}
|
||||
|
||||
open() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,13 +26,27 @@ export default class extends Controller {
|
||||
set #theme(theme) {
|
||||
localStorage.setItem("theme", theme)
|
||||
|
||||
if (theme === "auto") {
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-theme", theme)
|
||||
const currentTheme = document.documentElement.getAttribute("data-theme") || "auto"
|
||||
const hasChanged = currentTheme !== theme
|
||||
|
||||
const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches
|
||||
const animate = hasChanged && !prefersReducedMotion
|
||||
|
||||
const applyTheme = () => {
|
||||
if (theme === "auto") {
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-theme", theme)
|
||||
}
|
||||
|
||||
this.#updateButtons()
|
||||
}
|
||||
|
||||
this.#updateButtons()
|
||||
if (animate && document.startViewTransition) {
|
||||
document.startViewTransition(applyTheme)
|
||||
} else {
|
||||
applyTheme()
|
||||
}
|
||||
}
|
||||
|
||||
#applyStoredTheme() {
|
||||
|
||||
Reference in New Issue
Block a user