Keyboard navigation fixes (still not perfect but better).
This commit is contained in:
+56
-11
@@ -63,6 +63,7 @@ onUnmounted(watchDisconnect)
|
|||||||
const headerMain = ref<typeof HeaderMain | null>(null)
|
const headerMain = ref<typeof HeaderMain | null>(null)
|
||||||
let vert = 0
|
let vert = 0
|
||||||
let timer: any = null
|
let timer: any = null
|
||||||
|
|
||||||
const globalShortcutHandler = (event: KeyboardEvent) => {
|
const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||||
if (store.dialog) {
|
if (store.dialog) {
|
||||||
if (timer) {
|
if (timer) {
|
||||||
@@ -76,6 +77,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
const c = fileExplorer.isCursor()
|
const c = fileExplorer.isCursor()
|
||||||
const input = (event.target as HTMLElement).tagName === 'INPUT'
|
const input = (event.target as HTMLElement).tagName === 'INPUT'
|
||||||
const keyup = event.type === 'keyup'
|
const keyup = event.type === 'keyup'
|
||||||
|
|
||||||
|
// Always clear repeat timer on arrow keyup, even if focus moved to input
|
||||||
|
if (keyup && event.key.startsWith('Arrow') && timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
|
||||||
if (event.repeat) {
|
if (event.repeat) {
|
||||||
if (
|
if (
|
||||||
event.key === 'ArrowUp' ||
|
event.key === 'ArrowUp' ||
|
||||||
@@ -91,7 +99,22 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
//console.log("key pressed", event)
|
//console.log("key pressed", event)
|
||||||
/// Long if-else machina for all keys we handle here
|
/// Long if-else machina for all keys we handle here
|
||||||
let arrow = ''
|
let arrow = ''
|
||||||
if (!input && event.key.startsWith("Arrow")) arrow = event.key.slice(5).toLowerCase()
|
const inHeader = !!(event.target as HTMLElement).closest('.headermain')
|
||||||
|
const inBreadcrumb = !!(event.target as HTMLElement).closest('.breadcrumb')
|
||||||
|
// Handle arrows: in search input with text, only up/down; otherwise all arrows
|
||||||
|
const searchInput = inHeader && input
|
||||||
|
const searchHasText = searchInput && (event.target as HTMLInputElement).value
|
||||||
|
if (event.key.startsWith("Arrow")) {
|
||||||
|
const dir = event.key.slice(5).toLowerCase()
|
||||||
|
// In search with text: left/right move cursor, up/down navigate
|
||||||
|
if (searchHasText && (dir === 'left' || dir === 'right')) {
|
||||||
|
return // Let browser handle cursor movement
|
||||||
|
}
|
||||||
|
arrow = dir
|
||||||
|
}
|
||||||
|
if (arrow) {
|
||||||
|
// Arrow key handling - fall through to bottom
|
||||||
|
}
|
||||||
// Find: process on keydown so that we can bypass the built-in search hotkey
|
// Find: process on keydown so that we can bypass the built-in search hotkey
|
||||||
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
||||||
headerMain.value!.toggleSearchInput()
|
headerMain.value!.toggleSearchInput()
|
||||||
@@ -143,13 +166,34 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
timer = null
|
timer = null
|
||||||
}
|
}
|
||||||
let f: any
|
let f: any
|
||||||
switch (arrow) {
|
// Arrow navigation - always use fileExplorer for repeatable movement
|
||||||
case 'up': f = () => fileExplorer.up(event); break
|
if (arrow && !keyup) {
|
||||||
case 'down': f = () => fileExplorer.down(event); break
|
const focusSearch = () => (document.querySelector('.headermain input[type="search"]') as HTMLElement)?.focus()
|
||||||
case 'left': f = () => fileExplorer.left(event); break
|
const focusBreadcrumb = () => (document.querySelector('.breadcrumb') as HTMLElement)?.focus()
|
||||||
case 'right': f = () => fileExplorer.right(event); break
|
|
||||||
|
if (inBreadcrumb) {
|
||||||
|
// Breadcrumb: up→header (no repeat), down→files (with repeat)
|
||||||
|
if (arrow === 'up') { focusSearch(); f = null }
|
||||||
|
else if (arrow === 'down') { fileExplorer.focusFirst?.(); f = null }
|
||||||
|
} else if (inHeader) {
|
||||||
|
// Header: left/right navigate focusable items (buttons without tabindex=-1, search input, disk space)
|
||||||
|
const items = Array.from(document.querySelectorAll('.headermain button:not([tabindex=\"-1\"]), .headermain input[type=\"search\"], .headermain [tabindex=\"0\"]')) as HTMLElement[]
|
||||||
|
const idx = items.indexOf(document.activeElement as HTMLElement)
|
||||||
|
if (arrow === 'left' && idx > 0) { items[idx - 1]?.focus(); f = null }
|
||||||
|
else if (arrow === 'right' && idx < items.length - 1) { items[idx + 1]?.focus(); f = null }
|
||||||
|
else if (arrow === 'up') f = () => fileExplorer.up({ shiftKey: false })
|
||||||
|
else if (arrow === 'down') { focusBreadcrumb(); f = null }
|
||||||
|
} else {
|
||||||
|
// File explorer: normal navigation with repeat
|
||||||
|
switch (arrow) {
|
||||||
|
case 'up': f = () => fileExplorer.up(event); break
|
||||||
|
case 'down': f = () => fileExplorer.down(event); break
|
||||||
|
case 'left': f = () => fileExplorer.left(event); break
|
||||||
|
case 'right': f = () => fileExplorer.right(event); break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (f && !keyup) {
|
if (f) {
|
||||||
// Initial move, then t0 delay until repeats at tr intervals
|
// Initial move, then t0 delay until repeats at tr intervals
|
||||||
const t0 = 200, tr = event.altKey ? 20 : 100
|
const t0 = 200, tr = event.altKey ? 20 : 100
|
||||||
f()
|
f()
|
||||||
@@ -157,12 +201,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('keydown', globalShortcutHandler)
|
// Use capture phase to handle events before they reach target elements
|
||||||
window.addEventListener('keyup', globalShortcutHandler)
|
window.addEventListener('keydown', globalShortcutHandler, true)
|
||||||
|
window.addEventListener('keyup', globalShortcutHandler, true)
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('keydown', globalShortcutHandler)
|
window.removeEventListener('keydown', globalShortcutHandler, true)
|
||||||
window.removeEventListener('keyup', globalShortcutHandler)
|
window.removeEventListener('keyup', globalShortcutHandler, true)
|
||||||
})
|
})
|
||||||
export type { Path }
|
export type { Path }
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="disk-space-container" ref="containerRef">
|
<div class="disk-space-container" ref="containerRef" tabindex="0" @keydown.enter="handleClick" @keydown.space.prevent="handleClick">
|
||||||
<div
|
<div
|
||||||
ref="widgetRef"
|
ref="widgetRef"
|
||||||
class="disk-space-widget"
|
class="disk-space-widget"
|
||||||
@@ -352,6 +352,11 @@ onUnmounted(() => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 3em;
|
width: 3em;
|
||||||
height: 3em;
|
height: 3em;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk-space-container:focus .disk-space-widget:not(.expanded) {
|
||||||
|
filter: brightness(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.disk-space-widget {
|
.disk-space-widget {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import FileRenameInput from './FileRenameInput.vue'
|
import FileRenameInput from './FileRenameInput.vue'
|
||||||
@@ -135,6 +135,17 @@ defineExpose({
|
|||||||
isCursor() {
|
isCursor() {
|
||||||
return store.cursor && editing.value === null
|
return store.cursor && editing.value === null
|
||||||
},
|
},
|
||||||
|
focusFirst() {
|
||||||
|
const docs = props.documents
|
||||||
|
if (docs.length > 0) {
|
||||||
|
store.cursor = docs[0]!.key
|
||||||
|
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
||||||
|
nextTick(() => {
|
||||||
|
const a = document.querySelector(`#file-${store.cursor} .name a`) as HTMLAnchorElement | null
|
||||||
|
if (a) a.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
cursorRename() {
|
cursorRename() {
|
||||||
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
||||||
},
|
},
|
||||||
@@ -150,7 +161,12 @@ defineExpose({
|
|||||||
},
|
},
|
||||||
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
|
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
|
||||||
down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
|
down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
|
||||||
left(ev: KeyboardEvent) { router.back() },
|
left(ev: KeyboardEvent) {
|
||||||
|
// Only go back if we're in a subfolder (not at root)
|
||||||
|
if (props.path.length > 0) {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
},
|
||||||
right(ev: KeyboardEvent) {
|
right(ev: KeyboardEvent) {
|
||||||
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
|
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
|
||||||
if (a) a.click()
|
if (a) a.click()
|
||||||
@@ -190,9 +206,17 @@ defineExpose({
|
|||||||
scrolltimer = null
|
scrolltimer = null
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
if (moveto === N) focusBreadcrumb()
|
// When leaving the file list: up goes to breadcrumbs, down goes to header
|
||||||
|
if (moveto === N) {
|
||||||
|
if (d < 0) focusBreadcrumb()
|
||||||
|
else focusHeader()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const focusHeader = () => {
|
||||||
|
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
|
||||||
|
if (el) el.focus()
|
||||||
|
}
|
||||||
const focusBreadcrumb = () => {
|
const focusBreadcrumb = () => {
|
||||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||||
if (el) el.focus()
|
if (el) el.focus()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { connect, controlUrl } from '@/repositories/WS'
|
import { connect, controlUrl } from '@/repositories/WS'
|
||||||
@@ -82,6 +82,17 @@ defineExpose({
|
|||||||
isCursor() {
|
isCursor() {
|
||||||
return store.cursor && editing.value === null
|
return store.cursor && editing.value === null
|
||||||
},
|
},
|
||||||
|
focusFirst() {
|
||||||
|
const docs = props.documents
|
||||||
|
if (docs.length > 0) {
|
||||||
|
store.cursor = docs[0]!.key
|
||||||
|
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
||||||
|
nextTick(() => {
|
||||||
|
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null
|
||||||
|
if (a) a.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
cursorRename() {
|
cursorRename() {
|
||||||
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
||||||
},
|
},
|
||||||
@@ -144,9 +155,17 @@ defineExpose({
|
|||||||
scrolltimer = null
|
scrolltimer = null
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
if (moveto === N) focusBreadcrumb()
|
// When leaving the file list: up goes to breadcrumbs, down goes to header
|
||||||
|
if (moveto === N) {
|
||||||
|
if (d < 0) focusBreadcrumb()
|
||||||
|
else focusHeader()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const focusHeader = () => {
|
||||||
|
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
|
||||||
|
if (el) el.focus()
|
||||||
|
}
|
||||||
const focusBreadcrumb = () => {
|
const focusBreadcrumb = () => {
|
||||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||||
if (el) el.focus()
|
if (el) el.focus()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<div class="smallgap"></div>
|
<div class="smallgap"></div>
|
||||||
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
|
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
|
||||||
<div class="search-group">
|
<div class="search-group">
|
||||||
<SvgButton name="find" @click="focusSearch" tooltip="Search" />
|
<SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
|
||||||
<input
|
<input
|
||||||
ref="search"
|
ref="search"
|
||||||
type="search"
|
type="search"
|
||||||
@@ -159,6 +159,9 @@ defineExpose({
|
|||||||
.search-group:focus-within {
|
.search-group:focus-within {
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
.search-group:focus-within {
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-color, #f80);
|
||||||
|
}
|
||||||
.search-group:hover :deep(button.action-button),
|
.search-group:hover :deep(button.action-button),
|
||||||
.search-group:focus-within :deep(button.action-button) {
|
.search-group:focus-within :deep(button.action-button) {
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
class="action-button"
|
class="action-button"
|
||||||
|
:tabindex="tabindex"
|
||||||
@mouseenter="tooltip?.startHover"
|
@mouseenter="tooltip?.startHover"
|
||||||
@mousemove="tooltip?.updatePosition"
|
@mousemove="tooltip?.updatePosition"
|
||||||
@mouseleave="tooltip?.endHover"
|
@mouseleave="tooltip?.endHover"
|
||||||
@@ -19,6 +20,7 @@ import CursorTooltip from './CursorTooltip.vue'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
name: IconName
|
name: IconName
|
||||||
tooltip?: string
|
tooltip?: string
|
||||||
|
tabindex?: string | number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
|
|||||||
Reference in New Issue
Block a user