Skip to content

Commit

Permalink
Add a focus restore controller (#608)
Browse files Browse the repository at this point in the history
  • Loading branch information
krschacht authored Jan 27, 2025
1 parent f31e2da commit a576403
Show file tree
Hide file tree
Showing 3 changed files with 71 additions and 4 deletions.
48 changes: 48 additions & 0 deletions app/javascript/stimulus/focus_restore_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
connect() {
this.lastFocusedElement = null

document.addEventListener("focusin", this.boundHandleFocusIn)
document.addEventListener("focusout", this.boundHandleFocusOut)
document.addEventListener("visibilitychange", this.boundHandleVisibilityChange)
window.addEventListener("focus", this.boundHandleVisibilityChange)
window.addEventListener("blur", this.boundHandleVisibilityChange)
}

disconnect() {
document.removeEventListener("focusin", this.boundHandleFocusIn)
document.removeEventListener("focusout", this.boundHandleFocusOut)
document.removeEventListener("visibilitychange", this.boundHandleVisibilityChange)
window.removeEventListener("focus", this.boundHandleVisibilityChange)
window.removeEventListener("blur", this.boundHandleVisibilityChange)
}

boundHandleFocusIn = (event) => { this.handleFocusIn(event) }
handleFocusIn(event) {
if (event.target.matches("input, textarea, [contenteditable]")) {
this.lastFocusedElement = event.target
}
}

boundHandleFocusOut = (event) => { this.handleFocusOut(event) }
handleFocusOut(event) {
// Small delay to check if focus moved to another input or was truly lost
setTimeout(() => {
if (!document.activeElement.matches("input, textarea, [contenteditable]")) {
this.lastFocusedElement = null
}
}, 0)
}

boundHandleVisibilityChange = (event) => { this.handleVisibilityChange(event) }
handleVisibilityChange(event) {
if (!document.hidden && this.lastFocusedElement) {
this.lastFocusedElement.focus()
if ("selectionStart" in this.lastFocusedElement) {
this.lastFocusedElement.selectionStart = this.lastFocusedElement.selectionEnd = this.lastFocusedElement.value.length
}
}
}
}
25 changes: 22 additions & 3 deletions app/javascript/stimulus/image_upload_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ export default class extends Controller {
static targets = [ "file", "content", "preview" ]

connect() {
if (!this.hasFileTarget || !this.hasContentTarget) {
console.log("image-upload controller is skipping initialization because a target is missing")
return
}

this.dragCounter = 0
this.fileTarget.addEventListener("change", this.boundPreviewUpdate)
this.element.addEventListener("drop", this.boundDropped)
Expand All @@ -14,6 +19,8 @@ export default class extends Controller {
}

disconnect() {
if (!this.hasFileTarget || !this.hasContentTarget) return

this.fileTarget.removeEventListener("change", this.boundPreviewUpdate)
this.element.removeEventListener("drop", this.boundDropped)
this.contentTarget.removeEventListener("paste", this.boundPasted)
Expand All @@ -24,6 +31,8 @@ export default class extends Controller {

boundPreviewUpdate = () => { this.previewUpdate() }
previewUpdate() {
if (!this.hasFileTarget || !this.hasPreviewTarget) return

const input = this.fileTarget
if (input.files && input.files[0]) {
const reader = new FileReader()
Expand All @@ -38,19 +47,23 @@ export default class extends Controller {
}

previewRemove() {
if (!this.hasPreviewTarget) return

this.previewTarget.querySelector("img").src = ''
this.element.classList.remove("show-previews")
this.contentTarget.focus()
if (this.hasContentTarget) this.contentTarget.focus()
window.dispatchEvent(new CustomEvent('main-column-changed'))
}

boundDropped = (event) => { this.dropped(event) }
dropped(event) {
if (!this.hasFileTarget) return

event.preventDefault()
this.dragCounter = 0
const shade = this.element.querySelector("#drag-n-drop-shade")
if (shade) shade.remove()

let files = event.dataTransfer.files
this.fileTarget.files = files
this.previewUpdate()
Expand Down Expand Up @@ -82,6 +95,8 @@ export default class extends Controller {

boundPasted = async (event) => { this.pasted(event) }
async pasted(event) {
if (!this.hasFileTarget) return

const clipboardData =
event.clipboardData || event.originalEvent.clipboardData

Expand Down Expand Up @@ -113,14 +128,16 @@ export default class extends Controller {
displayDragnDropShade() {
const existing = this.element.querySelector("#drag-n-drop-shade")
if (existing) return

this.element.insertAdjacentHTML(
'beforeend',
'<div id="drag-n-drop-shade"></div>'
);
}

addImageToFileInput(dataURL, fileType) {
if (!this.hasFileTarget) return

const fileList = new DataTransfer()
const blob = this.dataURLtoBlob(dataURL, fileType)
fileList.items.add(
Expand All @@ -140,10 +157,12 @@ export default class extends Controller {
}

choose() {
if (!this.hasFileTarget) return
this.fileTarget.click()
}

remove() {
if (!this.hasFileTarget) return
this.fileTarget.value = ''
this.previewRemove()
}
Expand Down
2 changes: 1 addition & 1 deletion app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<%= Current.user.preferences[:dark_mode] %>
bg-white dark:bg-gray-800
"
data-controller="transition"
data-controller="transition focus-restore"
data-transition-toggle-class="nav-closed"
data-transition-target="transitionable"
>
Expand Down

0 comments on commit a576403

Please sign in to comment.