diff --git a/docs/composables.md b/docs/composables.md
new file mode 100644
index 0000000000..07980cfcdc
--- /dev/null
+++ b/docs/composables.md
@@ -0,0 +1,27 @@
+
+
+### Registration
+
+To use any composable, import and use it according to documentation or Vue guidelines, for example:
+
+```js static
+import { useIsMobile } from '@nextcloud/vue/dist/composables/useIsMobile.js'
+
+export default {
+ setup() {
+ return {
+ isMobile: useIsMobile(),
+ }
+ },
+}
+```
+or in `
+
+
+```
diff --git a/src/composables/index.js b/src/composables/index.js
index 4ef76388f5..016a4d0496 100644
--- a/src/composables/index.js
+++ b/src/composables/index.js
@@ -6,3 +6,4 @@
export * from './useIsFullscreen/index.js'
export * from './useIsMobile/index.js'
export * from './useFormatDateTime.ts'
+export * from './useHotKey/index.js'
diff --git a/src/composables/useHotKey/index.js b/src/composables/useHotKey/index.js
new file mode 100644
index 0000000000..0f78ee32d3
--- /dev/null
+++ b/src/composables/useHotKey/index.js
@@ -0,0 +1,81 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { onKeyStroke } from '@vueuse/core'
+
+const disableKeyboardShortcuts = window.OCP?.Accessibility?.disableKeyboardShortcuts?.()
+
+/**
+ * Check if event target (active element) is editable (allows input from keyboard) or NcModal is open
+ * If true, a hot key should not trigger the callback
+ * TODO discuss if we should abort on another interactive elements (button, a, e.t.c)
+ *
+ * @param {KeyboardEvent} event keyboard event
+ * @return {boolean} whether it should prevent callback
+ */
+function shouldIgnoreEvent(event) {
+ if (event.target instanceof HTMLInputElement
+ || event.target instanceof HTMLTextAreaElement
+ || event.target instanceof HTMLSelectElement
+ || event.target?.isContentEditable) {
+ return true
+ }
+ /** Abort if any modal/dialog opened */
+ return document.getElementsByClassName('modal-mask').length !== 0
+}
+
+const eventHandler = (callback, options) => (event) => {
+ if (!!options.ctrl !== event.ctrlKey) {
+ // Ctrl is required and not pressed, or the opposite
+ return
+ } else if (!!options.alt !== event.altKey) {
+ // Alt is required and not pressed, or the opposite
+ return
+ } else if (!!options.shift !== event.shiftKey) {
+ // Shift is required and not pressed, or the opposite
+ return
+ } else if (shouldIgnoreEvent(event)) {
+ // Keyboard shortcuts are disabled, because active element assumes input
+ return
+ }
+
+ if (options.prevent) {
+ event.preventDefault()
+ }
+ if (options.stop) {
+ event.stopPropagation()
+ }
+ callback(event)
+}
+
+/**
+ * @param {string} key - keyboard key or keys to listen to
+ * @param {Function} callback - callback function
+ * @param {object} options - composable options
+ * @see docs/composables/usekeystroke.md
+ */
+export function useHotKey(key, callback = () => {}, options = {}) {
+ if (disableKeyboardShortcuts) {
+ // Keyboard shortcuts are disabled
+ return () => {}
+ }
+
+ const stopKeyDown = onKeyStroke(key, eventHandler(callback, options), {
+ eventName: 'keydown',
+ dedupe: true,
+ passive: !options.prevent,
+ })
+
+ const stopKeyUp = options.push
+ ? onKeyStroke(key, eventHandler(callback, options), {
+ eventName: 'keyup',
+ passive: !options.prevent,
+ })
+ : () => {}
+
+ return () => {
+ stopKeyDown()
+ stopKeyUp()
+ }
+}
diff --git a/styleguide.config.js b/styleguide.config.js
index 728cc409cd..4cdd215928 100644
--- a/styleguide.config.js
+++ b/styleguide.config.js
@@ -139,6 +139,17 @@ module.exports = async () => {
},
],
},
+ {
+ name: 'Composables',
+ content: 'docs/composables.md',
+ sectionDepth: 1,
+ sections: [
+ {
+ name: 'useHotKey',
+ content: 'docs/composables/useHotKey.md',
+ },
+ ],
+ },
{
name: 'Components',
content: 'docs/components.md',