feat: refactor ActionsMenu for improved accessibility and keyboard navigation, including focus management and enhanced keyboard shortcut handling

This commit is contained in:
pandadev 2025-03-16 23:00:48 +01:00
parent b8238d01ca
commit 3a5e2cba7e
No known key found for this signature in database
GPG key ID: C39629DACB8E762F
4 changed files with 220 additions and 166 deletions

View file

@ -1,110 +1,119 @@
<template>
<div v-if="isVisible" class="actions" ref="menuRef">
<OverlayScrollbarsComponent ref="scrollbarsRef" class="actions-scrollable"
:options="{ scrollbars: { autoHide: 'scroll' } }">
<template v-if="searchQuery">
<div class="action-group">
<div v-if="allFilteredActions.length === 0" class="action no-results">
<div class="content">
<div class="title">No Results</div>
<div
id="actions-menu"
class="actions-menu-overlay"
:class="{ visible: isVisible }"
@click="$emit('close')"
tabindex="-1">
<div class="actions-menu" @click.stop>
<div v-if="isVisible" class="actions" ref="menuRef">
<OverlayScrollbarsComponent ref="scrollbarsRef" class="actions-scrollable"
:options="{ scrollbars: { autoHide: 'scroll' } }">
<template v-if="searchQuery">
<div class="action-group">
<div v-if="allFilteredActions.length === 0" class="action no-results">
<div class="content">
<div class="title">No Results</div>
</div>
</div>
<div v-else v-for="(action, index) in allFilteredActions" :key="action.action" class="action"
@click="executeAction(action)" :class="{ selected: isSelected && currentIndex === index }" :ref="(el) => {
if (currentIndex === index) setSelectedElement(el);
}
" :style="action.color ? { color: action.color } : {}">
<div class="content">
<component v-if="action.icon" :is="action.icon" class="icon" />
<div class="title">{{ action.title }}</div>
</div>
<div v-if="action.shortcut" class="shortcut">
<template v-for="(key, keyIndex) in parseShortcut(action.shortcut)" :key="keyIndex">
<component :is="key.component" v-if="key.component" :input="key.value" />
</template>
</div>
</div>
</div>
</div>
<div v-else v-for="(action, index) in allFilteredActions" :key="action.action" class="action"
@click="executeAction(action)" :class="{ selected: isSelected && currentIndex === index }" :ref="(el) => {
if (currentIndex === index) setSelectedElement(el);
}
" :style="action.color ? { color: action.color } : {}">
<div class="content">
<component v-if="action.icon" :is="action.icon" class="icon" />
<div class="title">{{ action.title }}</div>
</div>
<div v-if="action.shortcut" class="shortcut">
<template v-for="(key, keyIndex) in parseShortcut(action.shortcut)" :key="keyIndex">
<component :is="key.component" v-if="key.component" :input="key.value" />
</template>
</div>
</div>
</div>
</template>
</template>
<template v-else>
<div class="action-group">
<div v-for="(action, index) in topActions" :key="action.action" class="action" @click="executeAction(action)"
:class="{
selected:
isSelected && currentIndex === getActionIndex(index, 'top'),
}" :ref="(el) => {
if (currentIndex === getActionIndex(index, 'top'))
setSelectedElement(el);
}
">
<div class="content">
<component v-if="action.icon" :is="action.icon" class="icon" />
<div class="title">{{ action.title }}</div>
<template v-else>
<div class="action-group">
<div v-for="(action, index) in topActions" :key="action.action" class="action" @click="executeAction(action)"
:class="{
selected:
isSelected && currentIndex === getActionIndex(index, 'top'),
}" :ref="(el) => {
if (currentIndex === getActionIndex(index, 'top'))
setSelectedElement(el);
}
">
<div class="content">
<component v-if="action.icon" :is="action.icon" class="icon" />
<div class="title">{{ action.title }}</div>
</div>
<div v-if="action.shortcut" class="shortcut">
<template v-for="(key, index) in parseShortcut(action.shortcut)" :key="index">
<component :is="key.component" v-if="key.component" :input="key.value" />
</template>
</div>
</div>
<div class="divider" v-if="
topActions.length > 0 && typeSpecificActions.length > 0
"></div>
</div>
<div v-if="action.shortcut" class="shortcut">
<template v-for="(key, index) in parseShortcut(action.shortcut)" :key="index">
<component :is="key.component" v-if="key.component" :input="key.value" />
</template>
</div>
</div>
<div class="divider" v-if="
topActions.length > 0 && typeSpecificActions.length > 0
"></div>
</div>
<div v-if="typeSpecificActions.length > 0" class="action-group">
<div v-for="(action, index) in typeSpecificActions" :key="action.action" class="action"
@click="executeAction(action)" :class="{
selected:
isSelected &&
currentIndex === getActionIndex(index, 'specific'),
}" :ref="(el) => {
if (currentIndex === getActionIndex(index, 'specific'))
setSelectedElement(el);
}
">
<div class="content">
<component v-if="action.icon" :is="action.icon" class="icon" />
<div class="title">{{ action.title }}</div>
<div v-if="typeSpecificActions.length > 0" class="action-group">
<div v-for="(action, index) in typeSpecificActions" :key="action.action" class="action"
@click="executeAction(action)" :class="{
selected:
isSelected &&
currentIndex === getActionIndex(index, 'specific'),
}" :ref="(el) => {
if (currentIndex === getActionIndex(index, 'specific'))
setSelectedElement(el);
}
">
<div class="content">
<component v-if="action.icon" :is="action.icon" class="icon" />
<div class="title">{{ action.title }}</div>
</div>
<div v-if="action.shortcut" class="shortcut">
<template v-for="(key, index) in parseShortcut(action.shortcut)" :key="index">
<component :is="key.component" v-if="key.component" :input="key.value" />
</template>
</div>
</div>
<div class="divider" v-if="
typeSpecificActions.length > 0 && bottomActions.length > 0
"></div>
</div>
<div v-if="action.shortcut" class="shortcut">
<template v-for="(key, index) in parseShortcut(action.shortcut)" :key="index">
<component :is="key.component" v-if="key.component" :input="key.value" />
</template>
</div>
</div>
<div class="divider" v-if="
typeSpecificActions.length > 0 && bottomActions.length > 0
"></div>
</div>
<div class="action-group">
<div v-for="(action, index) in bottomActions" :key="action.action" class="action"
@click="executeAction(action)" :class="{
selected:
isSelected && currentIndex === getActionIndex(index, 'bottom'),
}" :ref="(el) => {
if (currentIndex === getActionIndex(index, 'bottom'))
setSelectedElement(el);
}
" :style="action.color ? { color: action.color } : {}">
<div class="content">
<component v-if="action.icon" :is="action.icon" class="icon" />
<div class="title">{{ action.title }}</div>
<div class="action-group">
<div v-for="(action, index) in bottomActions" :key="action.action" class="action"
@click="executeAction(action)" :class="{
selected:
isSelected && currentIndex === getActionIndex(index, 'bottom'),
}" :ref="(el) => {
if (currentIndex === getActionIndex(index, 'bottom'))
setSelectedElement(el);
}
" :style="action.color ? { color: action.color } : {}">
<div class="content">
<component v-if="action.icon" :is="action.icon" class="icon" />
<div class="title">{{ action.title }}</div>
</div>
<div v-if="action.shortcut" class="shortcut">
<template v-for="(key, index) in parseShortcut(action.shortcut)" :key="index">
<component :is="key.component" v-if="key.component" :input="key.value" />
</template>
</div>
</div>
</div>
<div v-if="action.shortcut" class="shortcut">
<template v-for="(key, index) in parseShortcut(action.shortcut)" :key="index">
<component :is="key.component" v-if="key.component" :input="key.value" />
</template>
</div>
</div>
</div>
</template>
</OverlayScrollbarsComponent>
</template>
</OverlayScrollbarsComponent>
<input type="text" v-model="searchQuery" class="search-input" placeholder="Search..." @keydown="handleSearchKeydown"
ref="searchInput" />
<input type="text" v-model="searchQuery" class="search-input" placeholder="Search..." @keydown="handleSearchKeydown"
ref="searchInput" />
</div>
</div>
</div>
</template>

View file

@ -125,6 +125,14 @@ const toggleActionsMenu = () => {
$keyboard.disableContext('actionsMenu');
$keyboard.enableContext('main');
}
nextTick(() => {
if (isActionsMenuVisible.value) {
document.getElementById('actions-menu')?.focus();
} else {
focusSearchInput();
}
});
};
const closeActionsMenu = () => {
@ -545,34 +553,18 @@ const setupEventListeners = async (): Promise<void> => {
}
focusSearchInput();
$keyboard.clearAll();
$keyboard.setupAppShortcuts({
onNavigateDown: selectNext,
onNavigateUp: selectPrevious,
onSelect: pasteSelectedItem,
onEscape: () => {
if (isActionsMenuVisible.value) {
closeActionsMenu();
} else {
hideApp();
}
},
onToggleActions: toggleActionsMenu,
contextName: 'main',
priority: $keyboard.PRIORITY.HIGH
});
$keyboard.disableContext('actionsMenu');
$keyboard.disableContext('settings');
$keyboard.enableContext('main');
if (isActionsMenuVisible.value) {
$keyboard.enableContext('actionsMenu');
} else {
$keyboard.enableContext('main');
}
});
await listen("tauri://blur", () => {
searchInput.value?.blur();
$keyboard.clearAll();
$keyboard.disableContext('main');
$keyboard.disableContext('actionsMenu');
});
$keyboard.setupAppShortcuts({
@ -588,8 +580,9 @@ const setupEventListeners = async (): Promise<void> => {
},
onToggleActions: toggleActionsMenu,
contextName: 'main',
priority: $keyboard.PRIORITY.LOW
priority: $keyboard.PRIORITY.HIGH
});
$keyboard.disableContext('settings');
$keyboard.enableContext('main');
};
@ -625,6 +618,24 @@ onMounted(async () => {
?.viewport?.addEventListener("scroll", handleScroll);
await setupEventListeners();
$keyboard.setupAppShortcuts({
onNavigateDown: selectNext,
onNavigateUp: selectPrevious,
onSelect: pasteSelectedItem,
onEscape: () => {
if (isActionsMenuVisible.value) {
closeActionsMenu();
} else {
hideApp();
}
},
onToggleActions: toggleActionsMenu,
contextName: 'main',
priority: $keyboard.PRIORITY.HIGH
});
$keyboard.disableContext('settings');
$keyboard.enableContext('main');
} catch (error) {
console.error("Error during onMounted:", error);
}

View file

@ -238,7 +238,6 @@ onMounted(async () => {
onUnmounted(() => {
$keyboard.disableContext("settings");
$keyboard.clearAll();
});
</script>

View file

@ -1,5 +1,4 @@
import { Key, keyboard } from "wrdu-keyboard";
import { platform } from "@tauri-apps/plugin-os";
type KeyboardHandler = (event: KeyboardEvent) => void;
@ -21,9 +20,6 @@ const PRIORITY = {
};
let currentOS = "windows";
const initOS = async () => {
currentOS = await platform();
};
const useKeyboard = {
PRIORITY,
@ -58,12 +54,29 @@ const useKeyboard = {
if (!handlersByContext[contextName]) {
useKeyboard.registerContext(contextName);
}
handlersByContext[contextName].push({
keys,
callback,
prevent: options.prevent ?? true,
priority: options.priority ?? PRIORITY.LOW,
});
const existingHandlerIndex = handlersByContext[contextName].findIndex(
(handler) =>
handler.keys.length === keys.length &&
handler.keys.every((key, i) => key === keys[i]) &&
handler.callback.toString() === callback.toString()
);
if (existingHandlerIndex !== -1) {
handlersByContext[contextName][existingHandlerIndex] = {
keys,
callback,
prevent: options.prevent ?? true,
priority: options.priority ?? PRIORITY.LOW,
};
} else {
handlersByContext[contextName].push({
keys,
callback,
prevent: options.prevent ?? true,
priority: options.priority ?? PRIORITY.LOW,
});
}
if (activeContexts.has(contextName)) {
initKeyboardHandlers();
@ -118,7 +131,7 @@ const useKeyboard = {
}
if (onToggleActions) {
const togglePriority = PRIORITY.HIGH;
const togglePriority = Math.max(priority, PRIORITY.HIGH);
if (currentOS === "macos") {
useKeyboard.on(
@ -169,48 +182,67 @@ const useKeyboard = {
const initKeyboardHandlers = () => {
keyboard.clear();
let allHandlers: Array<{ keys: Key[], callback: KeyboardHandler, prevent: boolean, priority: number, contextName: string }> = [];
let allHandlers: Array<{
keys: Key[];
callback: KeyboardHandler;
prevent: boolean;
priority: number;
contextName: string;
}> = [];
for (const contextName of activeContexts) {
const handlers = handlersByContext[contextName] || [];
allHandlers = [...allHandlers, ...handlers.map(handler => ({
...handler,
priority: handler.priority ?? PRIORITY.LOW,
contextName
}))];
allHandlers = [
...allHandlers,
...handlers.map((handler) => ({
...handler,
priority: handler.priority ?? PRIORITY.LOW,
contextName,
})),
];
}
allHandlers.sort((a, b) => b.priority - a.priority);
const handlersByKeyCombination: Record<string, Array<typeof allHandlers[0]>> = {};
const handlersByKeyCombination: Record<
string,
Array<(typeof allHandlers)[0]>
> = {};
allHandlers.forEach(handler => {
const keyCombo = handler.keys.join('+');
allHandlers.forEach((handler) => {
const keyCombo = handler.keys.sort().join("+");
if (!handlersByKeyCombination[keyCombo]) {
handlersByKeyCombination[keyCombo] = [];
}
handlersByKeyCombination[keyCombo].push(handler);
});
Object.values(handlersByKeyCombination).forEach(handlers => {
Object.entries(handlersByKeyCombination).forEach(([_keyCombo, handlers]) => {
handlers.sort((a, b) => b.priority - a.priority);
const handler = handlers[0];
const wrappedCallback: KeyboardHandler = (event) => {
const isMetaCombo = handler.keys.length > 1 &&
const isMetaCombo =
handler.keys.length > 1 &&
(handler.keys.includes(Key.LeftMeta) ||
handler.keys.includes(Key.RightMeta) ||
handler.keys.includes(Key.LeftControl) ||
handler.keys.includes(Key.RightControl));
handler.keys.includes(Key.RightMeta) ||
handler.keys.includes(Key.LeftControl) ||
handler.keys.includes(Key.RightControl));
const isNavigationKey = event.key === 'ArrowUp' ||
event.key === 'ArrowDown' ||
event.key === 'Enter' ||
event.key === 'Escape';
const isNavigationKey =
event.key === "ArrowUp" ||
event.key === "ArrowDown" ||
event.key === "Enter" ||
event.key === "Escape";
const isInInput = event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement;
const isInInput =
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement;
if (isMetaCombo || isNavigationKey || !isInInput) {
if (
(isMetaCombo || isNavigationKey || !isInInput) &&
activeContexts.has(handler.contextName)
) {
handler.callback(event);
}
};
@ -223,11 +255,14 @@ const initKeyboardHandlers = () => {
});
};
export default defineNuxtPlugin(async () => {
await initOS();
export default defineNuxtPlugin(async (nuxtApp) => {
initKeyboardHandlers();
nuxtApp.hook("page:finish", () => {
initKeyboardHandlers();
});
return {
provide: {
keyboard: {