From 28831a7050533d0e9e7af44ee7ffff6c5a64df56 Mon Sep 17 00:00:00 2001
From: j0code <42189560+j0code@users.noreply.github.com>
Date: Tue, 26 May 2026 18:05:22 +0200
Subject: [PATCH 1/2] feat: add keyboard autofocus
---
client/src/comps/App.ts | 75 ++++++++++++++++++++++++++++++
client/src/comps/ChatInput.ts | 25 +++++-----
client/src/comps/forms/Form.ts | 5 ++
client/src/comps/views/PostView.ts | 4 ++
client/src/comps/views/RoomView.ts | 4 ++
client/src/comps/views/View.ts | 2 +
6 files changed, 103 insertions(+), 12 deletions(-)
diff --git a/client/src/comps/App.ts b/client/src/comps/App.ts
index 779a7b7..5ea1190 100644
--- a/client/src/comps/App.ts
+++ b/client/src/comps/App.ts
@@ -6,6 +6,43 @@ import Form from "./forms/Form"
import View from "./views/View"
import Modal from "./Modal"
+/**
+ * Keyboard- and layout-independent key codes that trigger autofocus
+ *
+ * Keys starting with "Key" or "Digit" don't need to be listed explictly.
+ * AltGraph is layout-dependent and must not be listed.
+ * Numpad number keys and NumpadDecimal are dependent on Numpad key state and must not be listed.
+ */
+const autofocusCodes = [
+ // Row 1 (top)
+ "Backquote",
+ "Minus",
+ "Equal",
+ "Backspace",
+ // Row 2
+ "BracketLeft",
+ "BracketRight",
+ "Enter",
+ // Row 3
+ "CapsLock",
+ "Semicolon",
+ "Quote",
+ "Backslash",
+ // Row 4
+ "ShiftLeft",
+ "IntlBackslash",
+ "Period",
+ "Comma",
+ "Slash",
+ // Row 5
+ "Space",
+ // Numpad
+ "NumpadDivide",
+ "NumpadMultiply",
+ "NumpadSubtract",
+ "NumpadAdd",
+]
+
export default class App extends Component {
readonly roomList: RoomList
private currentView?: View | Form
@@ -27,6 +64,30 @@ export default class App extends Component {
})
void initMatrixClient()
+
+ document.addEventListener("keydown", event => {
+ const code = event.code // keyboard-/layout-independent key code
+ const key = event.key // key (respects layout, modifiers, and numpad key)
+ // console.log("keydown event fired on", event.target, "for key", code, key)
+ // console.log("modifiers:", ["ctrl", "shift", "alt", "meta"].filter(mod => event[mod + "Key"]).join(", "))
+
+ if (event.altKey || event.ctrlKey || event.metaKey) {
+ // console.log("modifier detected, skip")
+ return
+ }
+
+ if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || (event.target instanceof HTMLDivElement && event.target.hasAttribute("contenteditable"))) {
+ // console.log("is input, skip")
+ return
+ }
+
+ if (!code.startsWith("Key") && !code.startsWith("Digit") && !autofocusCodes.includes(code) && key != "AltGraph" && isNaN(Number(key)) && key != ",") {
+ // console.log("key doesn't trigger autofocus, skip")
+ return
+ }
+
+ this.autofocus()
+ })
}
updateChannelList() {
@@ -40,6 +101,8 @@ export default class App extends Component {
void view.reset(...args)
this.element.appendChild(view.element)
this.currentView = view
+
+ this.autofocus()
}
clearView() {
@@ -63,6 +126,8 @@ export default class App extends Component {
const element = modal.element as HTMLDialogElement
document.body.appendChild(element)
element.showModal()
+
+ modal.form.defaultTextInput?.focus()
}
closeModal(modal: Modal) {
@@ -86,4 +151,14 @@ export default class App extends Component {
getCurrentModal(): Modal | undefined {
return this.modals[this.modals.length - 1]
}
+
+ autofocus() {
+ const modal = this.getCurrentModal()
+
+ if (modal) {
+ modal.form.defaultTextInput?.focus()
+ } else {
+ this.currentView?.defaultTextInput?.focus()
+ }
+ }
}
diff --git a/client/src/comps/ChatInput.ts b/client/src/comps/ChatInput.ts
index 2e6a1be..4a819ea 100644
--- a/client/src/comps/ChatInput.ts
+++ b/client/src/comps/ChatInput.ts
@@ -8,6 +8,7 @@ import RoomView from "./views/RoomView"
// Credits to DeepSeek-R1, wow (edited though)
export default class ChatInput extends Component {
readonly emojiPicker: EmojiPicker
+ readonly input: HTMLDivElement
constructor(view: PostView | RoomView) {
super("div", { id: "chat-input-container" })
@@ -23,16 +24,16 @@ export default class ChatInput extends Component {
fileUploadLabel.appendChild(fileInput)
// Create chat input
- const chatInput = document.createElement("div")
- chatInput.className = "chat-input"
- chatInput.setAttribute("contenteditable", "true")
- chatInput.setAttribute("placeholder", "Message #channel")
- chatInput.addEventListener("keypress", e => {
+ this.input = document.createElement("div")
+ this.input.className = "chat-input"
+ this.input.setAttribute("contenteditable", "true")
+ this.input.setAttribute("placeholder", "Message #channel")
+ this.input.addEventListener("keypress", e => {
if (e.code == "Enter" && !e.shiftKey) {
e.preventDefault()
- const content = chatInput.innerText.trim()
+ const content = this.input.innerText.trim()
if (content == "") return
- chatInput.innerHTML = ""
+ this.input.innerHTML = ""
async function createMessage() {
// const forum_id = view.getCurrentForumId()
@@ -57,13 +58,13 @@ export default class ChatInput extends Component {
})
}
- console.log("Send MSG:", chatInput.innerText)
+ console.log("Send MSG:", this.input.innerText)
void createMessage()
}
})
- chatInput.addEventListener("input", () => {
+ this.input.addEventListener("input", () => {
// this fixes weird browser behavior
- if (chatInput.innerHTML == "
") chatInput.innerHTML = ""
+ if (this.input.innerHTML == "
") this.input.innerHTML = ""
})
// Create emoji button
@@ -77,13 +78,13 @@ export default class ChatInput extends Component {
"chat-input-emoji-picker",
"chat-input-container",
emoji => {
- chatInput.textContent += emoji.native
+ this.input.textContent += emoji.native
}
)
// Div-engers, Assemble!
this.element.appendChild(fileUploadLabel)
- this.element.appendChild(chatInput)
+ this.element.appendChild(this.input)
this.element.appendChild(emojiButton)
this.element.appendChild(this.emojiPicker.element)
}
diff --git a/client/src/comps/forms/Form.ts b/client/src/comps/forms/Form.ts
index 5352e99..9450012 100644
--- a/client/src/comps/forms/Form.ts
+++ b/client/src/comps/forms/Form.ts
@@ -48,4 +48,9 @@ export default abstract class Form<
const modal = new Modal(this)
app.openModal(modal)
}
+
+ get defaultTextInput(): HTMLElement | undefined {
+ const form = this.element as HTMLFormElement
+ return Array.from(form.elements).find(elem => !(elem instanceof HTMLOutputElement)) as HTMLElement | undefined
+ }
}
diff --git a/client/src/comps/views/PostView.ts b/client/src/comps/views/PostView.ts
index 8c19a6a..4c7a4a2 100644
--- a/client/src/comps/views/PostView.ts
+++ b/client/src/comps/views/PostView.ts
@@ -57,4 +57,8 @@ export default class PostView extends View<[string]> {
getCurrentForumId() {
return this.currentForumId
}
+
+ get defaultTextInput() {
+ return this.chatInput.input
+ }
}
diff --git a/client/src/comps/views/RoomView.ts b/client/src/comps/views/RoomView.ts
index df4408e..e3c12c2 100644
--- a/client/src/comps/views/RoomView.ts
+++ b/client/src/comps/views/RoomView.ts
@@ -90,4 +90,8 @@ export default class RoomView extends View {
getCurrentRoom() {
return this.currentRoom
}
+
+ get defaultTextInput() {
+ return this.chatInput.input
+ }
}
diff --git a/client/src/comps/views/View.ts b/client/src/comps/views/View.ts
index 55fa35f..372422e 100644
--- a/client/src/comps/views/View.ts
+++ b/client/src/comps/views/View.ts
@@ -23,4 +23,6 @@ export default abstract class View<
}
abstract reset(...args: ResetArgs): void | Promise
+
+ abstract defaultTextInput: HTMLElement | undefined
}
From 822f37a1951c6a9a767c6be62b6e344de98b86bc Mon Sep 17 00:00:00 2001
From: j0code <42189560+j0code@users.noreply.github.com>
Date: Tue, 26 May 2026 18:21:51 +0200
Subject: [PATCH 2/2] feat: differentiate between text-only autofocus and any
input autofocus
---
client/src/comps/App.ts | 9 +++++----
client/src/comps/forms/Form.ts | 21 ++++++++++++++++++++-
client/src/comps/views/View.ts | 4 ++++
3 files changed, 29 insertions(+), 5 deletions(-)
diff --git a/client/src/comps/App.ts b/client/src/comps/App.ts
index 5ea1190..8094476 100644
--- a/client/src/comps/App.ts
+++ b/client/src/comps/App.ts
@@ -86,7 +86,7 @@ export default class App extends Component {
return
}
- this.autofocus()
+ this.autofocus(true)
})
}
@@ -152,13 +152,14 @@ export default class App extends Component {
return this.modals[this.modals.length - 1]
}
- autofocus() {
+ autofocus(textOnly: boolean = false) {
const modal = this.getCurrentModal()
+ const getter = textOnly ? "defaultInput" : "defaultTextInput"
if (modal) {
- modal.form.defaultTextInput?.focus()
+ modal.form[getter]?.focus()
} else {
- this.currentView?.defaultTextInput?.focus()
+ this.currentView?.[getter]?.focus()
}
}
}
diff --git a/client/src/comps/forms/Form.ts b/client/src/comps/forms/Form.ts
index 9450012..554106c 100644
--- a/client/src/comps/forms/Form.ts
+++ b/client/src/comps/forms/Form.ts
@@ -51,6 +51,25 @@ export default abstract class Form<
get defaultTextInput(): HTMLElement | undefined {
const form = this.element as HTMLFormElement
- return Array.from(form.elements).find(elem => !(elem instanceof HTMLOutputElement)) as HTMLElement | undefined
+ return Array.from(form.elements).find(isTextInput)
+ }
+
+ get defaultInput(): HTMLElement | undefined {
+ const form = this.element as HTMLFormElement
+ return Array.from(form.elements).find(isFormInput)
}
}
+
+function isFormInput(formControl: Element): formControl is HTMLElement {
+ if (formControl instanceof HTMLFieldSetElement) return false
+ if (formControl instanceof HTMLOutputElement) return false
+ return true
+}
+
+function isTextInput(formControl: Element): formControl is HTMLElement {
+ if (formControl instanceof HTMLTextAreaElement) return true
+ if (formControl instanceof HTMLInputElement) {
+ return ["text", "number", "email", "password", "search", "tel", "url"].includes(formControl.type)
+ }
+ return false
+}
\ No newline at end of file
diff --git a/client/src/comps/views/View.ts b/client/src/comps/views/View.ts
index 372422e..c165245 100644
--- a/client/src/comps/views/View.ts
+++ b/client/src/comps/views/View.ts
@@ -25,4 +25,8 @@ export default abstract class View<
abstract reset(...args: ResetArgs): void | Promise
abstract defaultTextInput: HTMLElement | undefined
+
+ get defaultInput() {
+ return this.defaultTextInput
+ }
}